mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 13:16:54 -04:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e1c10c30 | ||
|
|
4a1acca879 | ||
|
|
a95b3f0a85 | ||
|
|
b7eab1a377 | ||
|
|
25a8200110 | ||
|
|
6443f5fb4c | ||
|
|
af54f4aed1 | ||
|
|
1a4167936e | ||
|
|
54f4baf6f9 | ||
|
|
3cd0f6c954 | ||
|
|
04db697b5d | ||
|
|
d68a1cbc2d | ||
|
|
bcf2cefd4b | ||
|
|
4f6e617555 | ||
|
|
40a7c20430 | ||
|
|
caa28cb7ab | ||
|
|
e22c4ec7ab |
No files matched your search
+10
-1
@@ -1,5 +1,6 @@
|
||||
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
|
||||
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
|
||||
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
|
||||
# keeps working (#2204).
|
||||
#
|
||||
# cargo nextest run --workspace # all tests except doctests
|
||||
# cargo test --doc --workspace # nextest does not run doctests
|
||||
@@ -13,3 +14,11 @@ gpu = { max-threads = 4 }
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
|
||||
test-group = 'gpu'
|
||||
|
||||
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
|
||||
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
|
||||
# at the end of the log. The default profile's GPU override still applies.
|
||||
[profile.ci]
|
||||
fail-fast = true
|
||||
slow-timeout = { period = "60s", terminate-after = 10 }
|
||||
failure-output = "immediate-final"
|
||||
@@ -131,21 +131,34 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
|
||||
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
with:
|
||||
tool: [email protected]
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
|
||||
# Separate native commands into steps so Windows reports either command's failure.
|
||||
- name: Test
|
||||
run: cargo test --workspace --locked --features heif
|
||||
run: cargo nextest run --workspace --locked --features heif --profile ci
|
||||
|
||||
- name: Doctests
|
||||
run: cargo test --doc --workspace --locked --features heif
|
||||
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
||||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
|
||||
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
|
||||
# --features heif` of the Test step: any other package or feature selection resolves a
|
||||
# different feature set, recompiles the whole dependency graph into the same target dir and
|
||||
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
|
||||
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
|
||||
# not what is built); engine and the apps only pass text on to them (#6120).
|
||||
- name: Check out craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
@@ -159,7 +172,9 @@ jobs:
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: cargo test --workspace --locked --features heif
|
||||
run: >-
|
||||
cargo nextest run --workspace --locked --features heif --profile ci
|
||||
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
|
||||
|
||||
corpus:
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -741,10 +741,32 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Layer Properties",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
|
||||
r##"{"layer":id?,"layers":[id,…]? (the same change to each, one step: the Layers panel's controls with several layers selected),"name":str? (one layer only),"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
|
||||
has_layer,
|
||||
|s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
// Several layers at once (the Layers panel with several selected, as in Photoshop),
|
||||
// else the given or active one.
|
||||
let ids: Vec<LayerId> = match p.get("layers") {
|
||||
None | Some(Value::Null) => vec![layer_param(s, p)?],
|
||||
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => {
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let mut ids = Vec::with_capacity(a.len());
|
||||
for v in a {
|
||||
let id = v.as_u64().map(LayerId).ok_or_else(|| bad("layer.setProps", format!("`layers` holds layer ids, got {v}")))?;
|
||||
if doc.layer(id).is_none() {
|
||||
return Err(EngineError::NoLayer(id));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
Some(v) => return Err(bad("layer.setProps", format!("`layers` is a non-empty list of layer ids, got {v}"))),
|
||||
};
|
||||
if ids.len() > 1 && p.get("name").is_some() {
|
||||
return Err(bad("layer.setProps", "`name` renames one layer, not several"));
|
||||
}
|
||||
// Opacity and Fill are fractions here, while the Layer Style commands take
|
||||
// percentages: a value outside 0..1 is refused, never clamped to 100 % (#5242).
|
||||
let unit = |key: &str| -> Result<Option<f32>> {
|
||||
@@ -755,9 +777,19 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let (opacity, fill) = (unit("opacity")?, unit("fill")?);
|
||||
let only_visibility =
|
||||
p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce")));
|
||||
let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" };
|
||||
// Photoshop's History names for a single kind of change; a mix is "Layer Properties".
|
||||
let changed: Vec<&str> = p
|
||||
.as_object()
|
||||
.map(|o| o.keys().map(String::as_str).filter(|k| !matches!(*k, "layer" | "layers" | "coalesce")).collect())
|
||||
.unwrap_or_default();
|
||||
let label = match changed.as_slice() {
|
||||
["visible"] => "Layer Visibility",
|
||||
["opacity"] => "Opacity Change",
|
||||
["fill"] => "Fill Opacity Change",
|
||||
["blend"] => "Blending Change",
|
||||
["name"] => "Rename Layer",
|
||||
_ => "Layer Properties",
|
||||
};
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
// Clipping and channel changes reach the layers around it: recomposite everything.
|
||||
let local = p.get("clipped").is_none() && p.get("channels").is_none();
|
||||
@@ -766,47 +798,54 @@ fn build() -> Vec<CommandSpec> {
|
||||
// one that changes its look turns it into a normal layer ("Layer 0") first.
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0);
|
||||
let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal);
|
||||
if num("opacity") || num("fill") || blends {
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(v) = p.get("name").and_then(Value::as_str) {
|
||||
l.name = v.to_string();
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
l.visible = v;
|
||||
}
|
||||
if let Some(v) = opacity {
|
||||
l.opacity = v;
|
||||
}
|
||||
if let Some(v) = fill {
|
||||
l.fill_opacity = v;
|
||||
}
|
||||
if let Some(v) = p.get("blend").and_then(Value::as_str) {
|
||||
l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
|
||||
}
|
||||
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
|
||||
l.clipped = v;
|
||||
}
|
||||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
l.locks.all = v;
|
||||
}
|
||||
if let Some(Value::Array(ch)) = p.get("channels") {
|
||||
// Blending Options › Advanced Blending › Channels: unchecked = left out.
|
||||
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
|
||||
}
|
||||
if let Some(Value::Object(m)) = p.get("locks") {
|
||||
for (k, v) in m {
|
||||
let v = v.as_bool().unwrap_or(false);
|
||||
if !l.locks.set(k, v) {
|
||||
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
|
||||
for &id in &ids {
|
||||
if num("opacity") || num("fill") || blends {
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(v) = p.get("name").and_then(Value::as_str) {
|
||||
l.name = v.to_string();
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
l.visible = v;
|
||||
}
|
||||
if let Some(v) = opacity {
|
||||
l.opacity = v;
|
||||
}
|
||||
if let Some(v) = fill {
|
||||
l.fill_opacity = v;
|
||||
}
|
||||
if let Some(v) = p.get("blend").and_then(Value::as_str) {
|
||||
let b = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
|
||||
// Pass Through is a group's mode only, as in Photoshop.
|
||||
if b == photocraft_color::BlendMode::PassThrough && !l.is_group() {
|
||||
return Err(bad("layer.setProps", format!("Pass Through applies to groups only; layer {} isn't one", l.id.0)));
|
||||
}
|
||||
l.blend = b;
|
||||
}
|
||||
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
|
||||
l.clipped = v;
|
||||
}
|
||||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
l.locks.all = v;
|
||||
}
|
||||
if let Some(Value::Array(ch)) = p.get("channels") {
|
||||
// Blending Options › Advanced Blending › Channels: unchecked = left out.
|
||||
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
|
||||
}
|
||||
if let Some(Value::Object(m)) = p.get("locks") {
|
||||
for (k, v) in m {
|
||||
let v = v.as_bool().unwrap_or(false);
|
||||
if !l.locks.set(k, v) {
|
||||
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if local {
|
||||
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
|
||||
crate::layer_multi_cmds::note_damage(s, &before, &ids);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -930,7 +930,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for id in ids {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
l.effects = fx.clone();
|
||||
l.blend = style.blend;
|
||||
// Pass Through is a group's mode: a group's style pastes onto other layers as
|
||||
// Normal, as a merged group's Pass Through becomes Normal.
|
||||
l.blend = if style.blend == photocraft_color::BlendMode::PassThrough && !l.is_group() {
|
||||
photocraft_color::BlendMode::Normal
|
||||
} else {
|
||||
style.blend
|
||||
};
|
||||
l.fill_opacity = style.fill;
|
||||
l.advanced = style.advanced;
|
||||
l.blend_if = blend_if.clone();
|
||||
|
||||
@@ -671,17 +671,7 @@ impl Session {
|
||||
return Ok(Started::Done(v));
|
||||
}
|
||||
// Pixel commands follow the Channels panel target unless the caller names one.
|
||||
let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone()));
|
||||
if let Err(why) = self.explicit_layer_enabled(spec, ¶ms).unwrap_or_else(|| self.precondition(spec, &run_params)) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
if let Some(why) = self.job_conflict(id, spec.journal) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
|
||||
if let Some(why) = crate::hidden_target::refusal(self, id, ¶ms) {
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
let run_params = self.admit(id, spec, ¶ms)?;
|
||||
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
|
||||
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
|
||||
self.jobs.spawn = background;
|
||||
|
||||
@@ -1487,7 +1487,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
link_layers(s)
|
||||
}),
|
||||
spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)),
|
||||
spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers),
|
||||
spec!(
|
||||
"layer.new.groupFromLayers",
|
||||
"Group from Layers…",
|
||||
&["Layer", "New"],
|
||||
None,
|
||||
r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##,
|
||||
has_layer,
|
||||
group_layers
|
||||
),
|
||||
spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)),
|
||||
spec!(
|
||||
"layer.lockLayers",
|
||||
|
||||
@@ -421,6 +421,12 @@ pub struct CopiedStyle {
|
||||
pub mode: photocraft_color::ColorMode,
|
||||
}
|
||||
|
||||
/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does
|
||||
/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and
|
||||
/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group
|
||||
/// or fill layer) is known only when they run.
|
||||
pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
docs: Vec<DocState>,
|
||||
@@ -432,8 +438,8 @@ pub struct Session {
|
||||
coalesce_request: Option<String>,
|
||||
/// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop).
|
||||
pub clipboard: Option<edit_cmds::Clip>,
|
||||
/// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced
|
||||
/// Blending switches.
|
||||
/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options
|
||||
/// ([`CopiedStyle`]: blend mode, fill opacity, Advanced Blending, Blend If, channels).
|
||||
pub style_clipboard: Option<CopiedStyle>,
|
||||
/// Shape path context menu: copied vector fill and stroke styles.
|
||||
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
|
||||
@@ -659,6 +665,53 @@ impl Session {
|
||||
self.live_commit = None;
|
||||
}
|
||||
|
||||
/// Check a call without running it: the command exists, its params have the shape
|
||||
/// [`Self::execute`] requires (New Layer's fully), and it can run now. For a caller that does
|
||||
/// something first only when the command will go through: Free Transform applies its box
|
||||
/// before a new layer is made.
|
||||
///
|
||||
/// Complete for the commands in [`PRECHECKED`]: there it accepts exactly what `execute`
|
||||
/// accepts (a test holds the two together). For others it checks only what they share.
|
||||
pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> {
|
||||
let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if !(params.is_object() || params.is_null()) {
|
||||
return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() });
|
||||
}
|
||||
crate::param_check::check(spec, params)?;
|
||||
crate::filters::validate_params(id, params)?;
|
||||
if id == "layer.new.layer" {
|
||||
crate::new_layer::parse(params)?;
|
||||
}
|
||||
// A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the
|
||||
// document doesn't have.
|
||||
if spec.params.contains("\"layer\":id")
|
||||
&& let Some(l) = commands::explicit_layer(self, params)?
|
||||
&& self.active().is_none_or(|st| st.doc.layer(l).is_none())
|
||||
{
|
||||
return Err(EngineError::BadParams { cmd: id.to_string(), msg: format!("no layer {}", l.0) });
|
||||
}
|
||||
self.admit(id, spec, params).map(|_| ())
|
||||
}
|
||||
|
||||
/// What `dispatch` checks once the params are valid and a floating selection has dropped:
|
||||
/// the precondition (on a named layer for commands that take one), a job running on the
|
||||
/// document, a hidden target. The run params (the Channels panel's target filled in) when it
|
||||
/// may run.
|
||||
pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result<Value> {
|
||||
let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone()));
|
||||
if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
if let Some(why) = self.job_conflict(id, spec.journal) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
|
||||
if let Some(why) = hidden_target::refusal(self, id, params) {
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
Ok(run_params)
|
||||
}
|
||||
|
||||
/// Is the command currently runnable? (drives menu enablement)
|
||||
pub fn is_enabled(&self, id: &str) -> bool {
|
||||
self.is_enabled_with(id, &Value::Null)
|
||||
|
||||
@@ -24,9 +24,49 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A New Layer call's params, checked.
|
||||
pub(crate) struct NewLayer {
|
||||
clip: bool,
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
label: LabelColor,
|
||||
neutral: Option<[f32; 3]>,
|
||||
below: bool,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the
|
||||
/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in.
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?;
|
||||
let id = s.edit("New Layer", |doc, active| {
|
||||
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
|
||||
let mut layer = Layer::raster(name, doc.pixel_format());
|
||||
layer.blend = mode;
|
||||
layer.opacity = opacity;
|
||||
layer.label = label;
|
||||
if let Some(rgb) = neutral {
|
||||
let bounds = doc.bounds();
|
||||
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
|
||||
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
surface.fill_rect(bounds, &px);
|
||||
}
|
||||
// Nothing may sit under the Background: below it the layer lands above it instead.
|
||||
let id = match *active {
|
||||
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
|
||||
_ => doc.insert_above(*active, layer),
|
||||
};
|
||||
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
})?;
|
||||
crate::layer_multi_cmds::note_inert_insert(s, id);
|
||||
Ok(json!({ "layer": id.0 }))
|
||||
}
|
||||
|
||||
/// Check a New Layer call's params without running it (Free Transform applies its box before a
|
||||
/// new layer only when the new layer can be made).
|
||||
pub(crate) fn parse(p: &Value) -> Result<NewLayer> {
|
||||
let flag = |k: &str| match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))),
|
||||
@@ -62,29 +102,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()),
|
||||
};
|
||||
let id = s.edit("New Layer", |doc, active| {
|
||||
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
|
||||
let mut layer = Layer::raster(name, doc.pixel_format());
|
||||
layer.blend = mode;
|
||||
layer.opacity = opacity;
|
||||
layer.label = label;
|
||||
if let Some(rgb) = neutral {
|
||||
let bounds = doc.bounds();
|
||||
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
|
||||
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
surface.fill_rect(bounds, &px);
|
||||
}
|
||||
// Nothing may sit under the Background: below it the layer lands above it instead.
|
||||
let id = match *active {
|
||||
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
|
||||
_ => doc.insert_above(*active, layer),
|
||||
};
|
||||
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
})?;
|
||||
crate::layer_multi_cmds::note_inert_insert(s, id);
|
||||
Ok(json!({ "layer": id.0 }))
|
||||
Ok(NewLayer { clip, mode, opacity, label, neutral, below, name })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! `layer.setProps` on several layers at once (the Layers panel with several selected, as in
|
||||
//! Photoshop), and Photoshop's History names for each kind of change.
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn doc() -> (Session, Vec<u64>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
|
||||
let ids = (0..3).map(|_| s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()).collect();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> photocraft_doc::Layer {
|
||||
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> String {
|
||||
s.active().unwrap().history.entries().last().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_change_reaches_every_listed_layer_in_one_step() {
|
||||
let (mut s, ids) = doc();
|
||||
let steps = s.active().unwrap().history.entries().len();
|
||||
s.execute("layer.setProps", json!({"layers": [ids[0], ids[2]], "opacity": 0.4})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Opacity Change");
|
||||
assert_eq!((layer(&s, ids[0]).opacity, layer(&s, ids[1]).opacity, layer(&s, ids[2]).opacity), (0.4, 1.0, 0.4));
|
||||
s.execute("layer.setProps", json!({"layers": ids, "blend": "Multiply"})).unwrap();
|
||||
assert_eq!(last_step(&s), "Blending Change");
|
||||
assert!(ids.iter().all(|id| layer(&s, *id).blend == photocraft_color::BlendMode::Multiply));
|
||||
s.execute("layer.setProps", json!({"layers": [ids[1], ids[1], ids[2]], "locks": {"all": true}})).unwrap();
|
||||
assert!(layer(&s, ids[1]).locks.all && layer(&s, ids[2]).locks.all && !layer(&s, ids[0]).locks.all, "duplicates are harmless");
|
||||
s.execute("layer.setProps", json!({"layers": [ids[0]], "fill": 0.5})).unwrap();
|
||||
assert_eq!(last_step(&s), "Fill Opacity Change");
|
||||
s.execute("layer.setProps", json!({"layer": ids[0], "name": "Sky"})).unwrap();
|
||||
assert_eq!(last_step(&s), "Rename Layer");
|
||||
s.execute("layer.setProps", json!({"layer": ids[0], "opacity": 0.3, "fill": 0.3})).unwrap();
|
||||
assert_eq!(last_step(&s), "Layer Properties", "a mix keeps the general name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_layer_lists_change_nothing() {
|
||||
let (mut s, ids) = doc();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
for p in [
|
||||
json!({"layers": [], "opacity": 0.5}),
|
||||
json!({"layers": "all", "opacity": 0.5}),
|
||||
json!({"layers": ["x"], "opacity": 0.5}),
|
||||
json!({"layers": [ids[0], 999_999], "opacity": 0.5}),
|
||||
json!({"layers": [ids[0], ids[1]], "name": "Both"}),
|
||||
json!({"layers": [ids[0], ids[1]], "opacity": 2.0}),
|
||||
] {
|
||||
assert!(s.execute("layer.setProps", p.clone()).is_err(), "{p}");
|
||||
assert_eq!(s.active().unwrap().doc.layers, before.layers, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_through_is_for_groups_only() {
|
||||
let (mut s, ids) = doc();
|
||||
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
// A pixel layer, alone or with a group, can't take Pass Through; nothing changes.
|
||||
assert!(s.execute("layer.setProps", json!({"layer": ids[0], "blend": "Pass Through"})).is_err());
|
||||
let before = (layer(&s, ids[0]).blend, layer(&s, g).blend);
|
||||
assert!(s.execute("layer.setProps", json!({"layers": [g, ids[0]], "blend": "Pass Through"})).is_err());
|
||||
assert_eq!((layer(&s, ids[0]).blend, layer(&s, g).blend), before);
|
||||
s.execute("layer.setProps", json!({"layers": [g], "blend": "Normal"})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": g, "blend": "Pass Through"})).unwrap();
|
||||
assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers
|
||||
//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859).
|
||||
|
||||
use photocraft_engine::{PRECHECKED, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Documents in different states: a Background only, a pixel layer active, a group active, a
|
||||
/// selection, no document.
|
||||
fn states() -> Vec<(&'static str, Session)> {
|
||||
let mut out = Vec::new();
|
||||
out.push(("no document", Session::new()));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
out.push(("background only", s));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
out.push(("pixel layer", s));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
|
||||
s.execute("layer.new.group", json!({})).unwrap();
|
||||
out.push(("group, no background", s));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
out.push(("selection", s));
|
||||
out
|
||||
}
|
||||
|
||||
fn params() -> Vec<Value> {
|
||||
vec![
|
||||
Value::Null,
|
||||
json!({}),
|
||||
json!([1]),
|
||||
json!("x"),
|
||||
json!({"mode": "Bogus"}),
|
||||
json!({"mode": "Multiply"}),
|
||||
json!({"fillNeutral": true}),
|
||||
json!({"mode": "Overlay", "fillNeutral": true}),
|
||||
json!({"position": "x"}),
|
||||
json!({"position": "below"}),
|
||||
json!({"opacity": "half"}),
|
||||
json!({"name": 5}),
|
||||
json!({"name": "ok"}),
|
||||
json!({"color": "chartreuse"}),
|
||||
json!({"layer": 999_999}),
|
||||
json!({"layer": "Layer 1"}),
|
||||
json!({"layer": "no such layer"}),
|
||||
json!({"layer": true}),
|
||||
json!({"ifPresent": true}),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precheck_agrees_with_execute() {
|
||||
let mut disagreements = Vec::new();
|
||||
for id in PRECHECKED {
|
||||
for p in params() {
|
||||
for (state, _) in states() {
|
||||
// A fresh copy of the state for each call: execute changes it.
|
||||
let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap();
|
||||
let pre = s.precheck(id, &p).is_ok();
|
||||
let ran = s.execute(id, p.clone()).is_ok();
|
||||
if pre != ran {
|
||||
disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(disagreements.is_empty(), "{disagreements:#?}");
|
||||
}
|
||||
@@ -97,3 +97,19 @@ fn several_layers_align_to_an_active_selection() {
|
||||
assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_pass_through_pastes_onto_a_pixel_layer_as_normal() {
|
||||
let mut s = Session::new();
|
||||
let (a, _) = new_doc(&mut s, "rgb");
|
||||
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough);
|
||||
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": g})).unwrap();
|
||||
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": a})).unwrap();
|
||||
assert_eq!(layer(&s, a).blend, photocraft_color::BlendMode::Normal, "Pass Through isn't a pixel layer's mode");
|
||||
// Onto another group it stays Pass Through.
|
||||
let g2 = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": g2, "blend": "Multiply"})).unwrap();
|
||||
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": g2})).unwrap();
|
||||
assert_eq!(layer(&s, g2).blend, photocraft_color::BlendMode::PassThrough);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Layers panel: hovering a mode in the open blend-mode list previews it on the active layer
|
||||
//! (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or
|
||||
//! Layers panel: hovering a mode in the open blend-mode list previews it on the layers it would
|
||||
//! change, the active one or every selected one (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or
|
||||
//! closing it without a choice, shows the document again, and choosing a mode commits it through
|
||||
//! `layer.setProps` (one history step). The canvas re-renders once per hovered mode, and only
|
||||
//! where the layer's blending can change pixels.
|
||||
@@ -15,17 +15,17 @@ use crate::PhotocraftApp;
|
||||
/// Preview keys (`canvas::display_doc`) are `BASE` plus a hash of the revision and the mode.
|
||||
const BASE: u64 = 1 << 37;
|
||||
|
||||
/// A blend mode hovered for a layer of a document at a revision.
|
||||
/// A blend mode hovered for layers of a document at a revision.
|
||||
pub(crate) struct BlendPreview {
|
||||
doc: DocId,
|
||||
revision: u64,
|
||||
layer: LayerId,
|
||||
layers: Vec<LayerId>,
|
||||
/// The mode under the pointer; `None` once it left the list (the rest stays for the
|
||||
/// canvas's damage on its way back to the document).
|
||||
mode: Option<BlendMode>,
|
||||
/// The frame the list was last drawn in: a list no longer drawn previews nothing.
|
||||
frame: u64,
|
||||
/// Where the layer's blending can change pixels; `None` for anywhere.
|
||||
/// Where the layers' blending can change pixels; `None` for anywhere.
|
||||
area: Option<Rect>,
|
||||
/// The document shown for `mode`, with its key.
|
||||
shown: Option<(u64, Arc<Document>)>,
|
||||
@@ -33,11 +33,11 @@ pub(crate) struct BlendPreview {
|
||||
|
||||
/// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the
|
||||
/// pointer in its open list, if any.
|
||||
pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendMode>) {
|
||||
pub(crate) fn hover(app: &mut PhotocraftApp, layers: &[LayerId], mode: Option<BlendMode>) {
|
||||
let frame = app.frame;
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let (doc, revision) = (st.doc.id, st.revision);
|
||||
if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layer == layer) {
|
||||
if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layers == layers) {
|
||||
p.frame = frame;
|
||||
if mode.is_none() {
|
||||
p.shown = None;
|
||||
@@ -46,8 +46,15 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendM
|
||||
return;
|
||||
}
|
||||
app.blend_preview = mode.and_then(|mode| {
|
||||
let area = photocraft_compose::change_bounds(st.doc.layer(layer)?, st.doc.bounds());
|
||||
Some(BlendPreview { doc, revision, layer, mode: Some(mode), frame, area, shown: None })
|
||||
// Anywhere when any layer can change anything; else the union of their areas.
|
||||
let mut area: Option<Rect> = Some(Rect::EMPTY);
|
||||
for &id in layers {
|
||||
area = match (area, photocraft_compose::change_bounds(st.doc.layer(id)?, st.doc.bounds())) {
|
||||
(Some(a), Some(b)) => Some(if a.is_empty() { b } else { a.union(&b) }),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Some(BlendPreview { doc, revision, layers: layers.to_vec(), mode: Some(mode), frame, area, shown: None })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,15 +64,23 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
|
||||
let st = app.session.documents().get(idx)?;
|
||||
let p = app.blend_preview.as_mut().filter(|p| p.doc == st.doc.id && p.revision == st.revision && p.frame + 1 >= frame)?;
|
||||
let mode = p.mode?;
|
||||
let key = preview_key(p.revision, p.layer, mode);
|
||||
let key = preview_key(p.revision, &p.layers, mode);
|
||||
if let Some((k, shown)) = &p.shown
|
||||
&& *k == key
|
||||
{
|
||||
return Some((shown.clone(), key));
|
||||
}
|
||||
let mut doc = (*st.doc).clone();
|
||||
let l = doc.layer_mut(p.layer).filter(|l| l.blend != mode)?;
|
||||
l.blend = mode;
|
||||
let mut changed = false;
|
||||
for &id in &p.layers {
|
||||
if let Some(l) = doc.layer_mut(id).filter(|l| l.blend != mode) {
|
||||
l.blend = mode;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
// Duotone documents display through their inks.
|
||||
let doc = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc));
|
||||
p.shown = Some((key, doc.clone()));
|
||||
@@ -73,7 +88,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
|
||||
}
|
||||
|
||||
/// What changed between preview (or document) key `seen` and `now` of document `doc` at
|
||||
/// `revision`: the hovered layer's area. `None` when either key is another kind of preview, or
|
||||
/// `revision`: the hovered layers' area. `None` when either key is another kind of preview, or
|
||||
/// the layer can change anything (the canvas then recomposites everything).
|
||||
pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option<Rect> {
|
||||
let p = app.blend_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?;
|
||||
@@ -85,8 +100,9 @@ fn is_preview_key(key: u64) -> bool {
|
||||
(BASE..BASE << 1).contains(&key)
|
||||
}
|
||||
|
||||
fn preview_key(revision: u64, layer: LayerId, mode: BlendMode) -> u64 {
|
||||
let h = mode.label().bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
|
||||
fn preview_key(revision: u64, layers: &[LayerId], mode: BlendMode) -> u64 {
|
||||
let seed = layers.iter().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, id| h.wrapping_mul(0x100_0000_01b3) ^ id.0);
|
||||
let h = mode.label().bytes().fold(seed, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
|
||||
(h & (BASE - 1)) | BASE
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() {
|
||||
let (s, top) = session();
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
let (undo, revision) = (steps(&app), app.session.active().unwrap().revision);
|
||||
hover(&mut app, top, Some(BlendMode::Multiply));
|
||||
hover(&mut app, &[top], Some(BlendMode::Multiply));
|
||||
let (shown, key) = display_doc(&mut app, 0).expect("previewed");
|
||||
assert_eq!(shown.layer(top).unwrap().blend, BlendMode::Multiply);
|
||||
let (again, same) = display_doc(&mut app, 0).unwrap();
|
||||
@@ -45,17 +45,17 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() {
|
||||
// From the document to the preview and between previews: only the layer's area redraws.
|
||||
let doc = app.session.active().unwrap().doc.id;
|
||||
assert_eq!(damage(&app, doc, revision, 0, key).map(|r| r.width()), Some(16));
|
||||
hover(&mut app, top, Some(BlendMode::Screen));
|
||||
hover(&mut app, &[top], Some(BlendMode::Screen));
|
||||
let (_, screen) = display_doc(&mut app, 0).unwrap();
|
||||
assert_ne!(screen, key);
|
||||
assert!(damage(&app, doc, revision, key, screen).is_some());
|
||||
// The current mode, or no mode, shows the document; and so does a list no longer drawn.
|
||||
hover(&mut app, top, Some(BlendMode::Normal));
|
||||
hover(&mut app, &[top], Some(BlendMode::Normal));
|
||||
assert!(display_doc(&mut app, 0).is_none());
|
||||
hover(&mut app, top, None);
|
||||
hover(&mut app, &[top], None);
|
||||
assert!(display_doc(&mut app, 0).is_none());
|
||||
assert!(damage(&app, doc, revision, screen, 0).is_some(), "back to the document: the layer's area");
|
||||
hover(&mut app, top, Some(BlendMode::Multiply));
|
||||
hover(&mut app, &[top], Some(BlendMode::Multiply));
|
||||
app.frame += 2;
|
||||
assert!(display_doc(&mut app, 0).is_none(), "a stale hover previews nothing");
|
||||
}
|
||||
@@ -124,3 +124,17 @@ fn moving_between_modes_keeps_a_preview_across_the_gaps_between_them() {
|
||||
}
|
||||
assert_eq!(previewed(&mut h), Some(BlendMode::Multiply));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hovered_mode_previews_on_every_selected_layer() {
|
||||
let (mut s, top) = session();
|
||||
let other = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap();
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
hover(&mut app, &[top, other], Some(BlendMode::Multiply));
|
||||
let (shown, key) = display_doc(&mut app, 0).expect("previewed");
|
||||
assert_eq!((shown.layer(top).unwrap().blend, shown.layer(other).unwrap().blend), (BlendMode::Multiply, BlendMode::Multiply));
|
||||
// A different set of layers is a different preview.
|
||||
hover(&mut app, &[top], Some(BlendMode::Multiply));
|
||||
assert_ne!(display_doc(&mut app, 0).unwrap().1, key);
|
||||
}
|
||||
@@ -3797,3 +3797,10 @@ navigator Zoom Zvětšení
|
||||
There are too many sets: delete one first. Sad je příliš mnoho: nejprve jednu smažte.
|
||||
set name název sady
|
||||
{set} (modified) {set} (změněno)
|
||||
Accept and Go to Conflict Přijmout a přejít ke konfliktu
|
||||
Changes undone. Změny vráceny.
|
||||
Choose a new shortcut for this command, or leave it without one. Zvolte pro tento příkaz novou zkratku, nebo ho nechte bez ní.
|
||||
Put this command's shortcuts back as they were Vrátit zkratky tohoto příkazu do původního stavu
|
||||
Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho
|
||||
Undo Changes Vrátit změny
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3191,3 +3191,10 @@ curves Smooth Glätten
|
||||
There are too many sets: delete one first. Zu viele Sätze: Löschen Sie zuerst einen.
|
||||
set name Name des Satzes
|
||||
{set} (modified) {set} (geändert)
|
||||
Accept and Go to Conflict Übernehmen und zum Konflikt
|
||||
Changes undone. Änderungen rückgängig gemacht.
|
||||
Choose a new shortcut for this command, or leave it without one. Wählen Sie einen neuen Kurzbefehl für diesen Befehl oder lassen Sie ihn ohne.
|
||||
Put this command's shortcuts back as they were Die Kurzbefehle dieses Befehls wiederherstellen
|
||||
Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen
|
||||
Undo Changes Änderungen rückgängig machen
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3889,3 +3889,10 @@ curves Smooth Εξομάλυνση
|
||||
There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα.
|
||||
set name όνομα συνόλου
|
||||
{set} (modified) {set} (τροποποιημένο)
|
||||
Accept and Go to Conflict Αποδοχή και μετάβαση στη σύγκρουση
|
||||
Changes undone. Οι αλλαγές αναιρέθηκαν.
|
||||
Choose a new shortcut for this command, or leave it without one. Επιλέξτε νέα συντόμευση για αυτήν την εντολή ή αφήστε την χωρίς.
|
||||
Put this command's shortcuts back as they were Επαναφορά των συντομεύσεων αυτής της εντολής όπως ήταν
|
||||
Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της
|
||||
Undo Changes Αναίρεση αλλαγών
|
||||
{shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί.
|
||||
|
Can't render this file because it contains an unexpected character in line 1030 and column 18.
|
@@ -4151,3 +4151,10 @@ navigator Zoom Zoom
|
||||
There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero.
|
||||
set name nombre del conjunto
|
||||
{set} (modified) {set} (modificado)
|
||||
Accept and Go to Conflict Aceptar e ir al conflicto
|
||||
Changes undone. Cambios deshechos.
|
||||
Choose a new shortcut for this command, or leave it without one. Elija un nuevo método abreviado para este comando o déjelo sin ninguno.
|
||||
Put this command's shortcuts back as they were Restaurar los métodos abreviados de este comando como estaban
|
||||
Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo
|
||||
Undo Changes Deshacer cambios
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar.
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -3194,3 +3194,10 @@ curves Smooth Lisser
|
||||
set name nom du jeu
|
||||
{set} (modified) {set} (modifié)
|
||||
Random Aléatoire
|
||||
Accept and Go to Conflict Accepter et aller au conflit
|
||||
Changes undone. Modifications annulées.
|
||||
Choose a new shortcut for this command, or leave it without one. Choisissez un nouveau raccourci pour cette commande ou laissez-la sans raccourci.
|
||||
Put this command's shortcuts back as they were Rétablir les raccourcis de cette commande
|
||||
Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner
|
||||
Undo Changes Annuler les modifications
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4108,3 +4108,10 @@ cameraRaw Yellow Sárga
|
||||
There are too many sets: delete one first. Túl sok készlet van: előbb töröljön egyet.
|
||||
set name készlet neve
|
||||
{set} (modified) {set} (módosítva)
|
||||
Accept and Go to Conflict Elfogadás és ugrás az ütközéshez
|
||||
Changes undone. Változások visszavonva.
|
||||
Choose a new shortcut for this command, or leave it without one. Válasszon új billentyűparancsot ehhez a parancshoz, vagy hagyja nélküle.
|
||||
Put this command's shortcuts back as they were A parancs billentyűparancsainak visszaállítása
|
||||
Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése
|
||||
Undo Changes Változások visszavonása
|
||||
{shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3181,3 +3181,10 @@ curves Smooth Haluskan
|
||||
set name nama set
|
||||
{set} (modified) {set} (diubah)
|
||||
Random Acak
|
||||
Accept and Go to Conflict Terima dan Buka Konflik
|
||||
Changes undone. Perubahan dibatalkan.
|
||||
Choose a new shortcut for this command, or leave it without one. Pilih pintasan baru untuk perintah ini, atau biarkan tanpa pintasan.
|
||||
Put this command's shortcuts back as they were Kembalikan pintasan perintah ini seperti semula
|
||||
Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu
|
||||
Undo Changes Batalkan Perubahan
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan.
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4310,3 +4310,10 @@ shape Appearance Aspetto
|
||||
There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno.
|
||||
set name nome del set
|
||||
{set} (modified) {set} (modificato)
|
||||
Accept and Go to Conflict Accetta e vai al conflitto
|
||||
Changes undone. Modifiche annullate.
|
||||
Choose a new shortcut for this command, or leave it without one. Scegli una nuova scorciatoia per questo comando o lascialo senza.
|
||||
Put this command's shortcuts back as they were Ripristina le scorciatoie di questo comando com'erano
|
||||
Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo
|
||||
Undo Changes Annulla modifiche
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata.
|
||||
|
Can't render this file because it contains an unexpected character in line 2654 and column 53.
|
@@ -3865,3 +3865,10 @@ curves Smooth スムーズ
|
||||
There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。
|
||||
set name セット名
|
||||
{set} (modified) {set}(変更あり)
|
||||
Accept and Go to Conflict 適用して競合へ移動
|
||||
Changes undone. 変更を取り消しました。
|
||||
Choose a new shortcut for this command, or leave it without one. このコマンドに新しいショートカットを選択するか、なしのままにしてください。
|
||||
Put this command's shortcuts back as they were このコマンドのショートカットを元に戻す
|
||||
Take the shortcut from the other command and select it もう一方のコマンドからショートカットを外して選択
|
||||
Undo Changes 変更を取り消し
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3367,3 +3367,10 @@ curves Smooth 매끄럽게
|
||||
There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요.
|
||||
set name 세트 이름
|
||||
{set} (modified) {set}(수정됨)
|
||||
Accept and Go to Conflict 적용 후 충돌 항목으로 이동
|
||||
Changes undone. 변경 사항을 취소했습니다.
|
||||
Choose a new shortcut for this command, or leave it without one. 이 명령의 새 단축키를 선택하거나 단축키 없이 두세요.
|
||||
Put this command's shortcuts back as they were 이 명령의 단축키를 원래대로 되돌리기
|
||||
Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택
|
||||
Undo Changes 변경 사항 취소
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다.
|
||||
|
Can't render this file because it contains an unexpected character in line 3166 and column 53.
|
@@ -3949,3 +3949,10 @@ curves Smooth Vloeiend maken
|
||||
There are too many sets: delete one first. Er zijn te veel sets: verwijder er eerst een.
|
||||
set name naam van de set
|
||||
{set} (modified) {set} (gewijzigd)
|
||||
Accept and Go to Conflict Accepteren en naar conflict
|
||||
Changes undone. Wijzigingen ongedaan gemaakt.
|
||||
Choose a new shortcut for this command, or leave it without one. Kies een nieuwe sneltoets voor deze opdracht of laat hem zonder.
|
||||
Put this command's shortcuts back as they were De sneltoetsen van deze opdracht herstellen
|
||||
Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren
|
||||
Undo Changes Wijzigingen ongedaan maken
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen.
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3787,3 +3787,10 @@ curves Smooth Wygładź
|
||||
There are too many sets: delete one first. Zestawów jest za dużo: najpierw usuń jeden.
|
||||
set name nazwa zestawu
|
||||
{set} (modified) {set} (zmieniony)
|
||||
Accept and Go to Conflict Akceptuj i przejdź do konfliktu
|
||||
Changes undone. Zmiany cofnięte.
|
||||
Choose a new shortcut for this command, or leave it without one. Wybierz nowy skrót dla tego polecenia lub zostaw je bez skrótu.
|
||||
Put this command's shortcuts back as they were Przywróć skróty tego polecenia
|
||||
Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je
|
||||
Undo Changes Cofnij zmiany
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3979,3 +3979,10 @@ layerLabel No Color Sem cor
|
||||
There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro.
|
||||
set name nome do conjunto
|
||||
{set} (modified) {set} (modificado)
|
||||
Accept and Go to Conflict Aceitar e ir para o conflito
|
||||
Changes undone. Alterações desfeitas.
|
||||
Choose a new shortcut for this command, or leave it without one. Escolha um novo atalho para este comando ou deixe-o sem atalho.
|
||||
Put this command's shortcuts back as they were Restaurar os atalhos deste comando como estavam
|
||||
Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo
|
||||
Undo Changes Desfazer alterações
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3159,3 +3159,10 @@ curves Smooth Сгладить
|
||||
set name имя набора
|
||||
{set} (modified) {set} (изменён)
|
||||
Random Случайно
|
||||
Accept and Go to Conflict Принять и перейти к конфликту
|
||||
Changes undone. Изменения отменены.
|
||||
Choose a new shortcut for this command, or leave it without one. Выберите новую комбинацию для этой команды или оставьте её без комбинации.
|
||||
Put this command's shortcuts back as they were Вернуть комбинации этой команды
|
||||
Take the shortcut from the other command and select it Снять комбинацию с другой команды и выбрать её
|
||||
Undo Changes Отменить изменения
|
||||
{shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4262,3 +4262,10 @@ shape Appearance Izgled
|
||||
There are too many sets: delete one first. Previše skupova: prvo obrišite jedan.
|
||||
set name ime skupa
|
||||
{set} (modified) {set} (izmenjen)
|
||||
Accept and Go to Conflict Prihvati i idi na sukob
|
||||
Changes undone. Izmene su poništene.
|
||||
Choose a new shortcut for this command, or leave it without one. Izaberite novu prečicu za ovu komandu ili je ostavite bez prečice.
|
||||
Put this command's shortcuts back as they were Vrati prečice ove komande kakve su bile
|
||||
Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je
|
||||
Undo Changes Poništi izmene
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3919,3 +3919,10 @@ cameraData Lens Lens
|
||||
There are too many sets: delete one first. Çok fazla küme var: önce birini silin.
|
||||
set name küme adı
|
||||
{set} (modified) {set} (değiştirildi)
|
||||
Accept and Go to Conflict Kabul Et ve Çakışmaya Git
|
||||
Changes undone. Değişiklikler geri alındı.
|
||||
Choose a new shortcut for this command, or leave it without one. Bu komut için yeni bir kısayol seçin veya kısayolsuz bırakın.
|
||||
Put this command's shortcuts back as they were Bu komutun kısayollarını eski haline getir
|
||||
Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç
|
||||
Undo Changes Değişiklikleri Geri Al
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz.
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3888,3 +3888,10 @@ curves Smooth Згладити
|
||||
There are too many sets: delete one first. Забагато наборів: спершу видаліть один.
|
||||
set name назва набору
|
||||
{set} (modified) {set} (змінено)
|
||||
Accept and Go to Conflict Прийняти й перейти до конфлікту
|
||||
Changes undone. Зміни скасовано.
|
||||
Choose a new shortcut for this command, or leave it without one. Виберіть нове клавіатурне скорочення для цієї команди або залиште її без нього.
|
||||
Put this command's shortcuts back as they were Повернути клавіатурні скорочення цієї команди
|
||||
Take the shortcut from the other command and select it Забрати скорочення в іншої команди й вибрати її
|
||||
Undo Changes Скасувати зміни
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити.
|
||||
|
Can't render this file because it contains an unexpected character in line 1388 and column 18.
|
@@ -3675,3 +3675,10 @@ curves Smooth Làm mượt
|
||||
There are too many sets: delete one first. Có quá nhiều bộ: hãy xóa một bộ trước.
|
||||
set name tên bộ
|
||||
{set} (modified) {set} (đã sửa đổi)
|
||||
Accept and Go to Conflict Chấp nhận và đến xung đột
|
||||
Changes undone. Đã hoàn tác thay đổi.
|
||||
Choose a new shortcut for this command, or leave it without one. Chọn phím tắt mới cho lệnh này, hoặc để lệnh không có phím tắt.
|
||||
Put this command's shortcuts back as they were Khôi phục phím tắt của lệnh này như cũ
|
||||
Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó
|
||||
Undo Changes Hoàn tác thay đổi
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2805,3 +2805,10 @@ curves Smooth 平滑
|
||||
set name 组名称
|
||||
{set} (modified) {set}(已修改)
|
||||
Random 随机
|
||||
Accept and Go to Conflict 接受并转到冲突
|
||||
Changes undone. 已撤消更改。
|
||||
Choose a new shortcut for this command, or leave it without one. 为此命令选择新的快捷键,或保留为无快捷键。
|
||||
Put this command's shortcuts back as they were 将此命令的快捷键恢复原样
|
||||
Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它
|
||||
Undo Changes 撤消更改
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。
|
||||
|
Can't render this file because it contains an unexpected character in line 2600 and column 53.
|
@@ -2790,3 +2790,10 @@ curves Smooth 平滑
|
||||
set name 組合名稱
|
||||
{set} (modified) {set}(已修改)
|
||||
Random 隨機
|
||||
Accept and Go to Conflict 接受並前往衝突
|
||||
Changes undone. 已還原變更。
|
||||
Choose a new shortcut for this command, or leave it without one. 為此指令選擇新的快速鍵,或保持沒有快速鍵。
|
||||
Put this command's shortcuts back as they were 將此指令的快速鍵恢復原狀
|
||||
Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它
|
||||
Undo Changes 還原變更
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -0,0 +1,78 @@
|
||||
//! The Layers panel's blend mode, Opacity, Fill and lock controls change every selected layer,
|
||||
//! as in Photoshop, not only the active one.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
fn app() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
/// The Layers panel's lock buttons change every selected layer (as do blend mode, Opacity and
|
||||
/// Fill), not only the active one.
|
||||
#[test]
|
||||
fn the_layers_panel_locks_every_selected_layer() {
|
||||
let mut h = app();
|
||||
let ids: Vec<u64> = h.state().session.active().unwrap().doc.layers.iter().map(|l| l.id.0).collect();
|
||||
let extra = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
h.state_mut().run("layer.select", json!({"layer": ids[1], "mode": "add"})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active().unwrap().selected_layers().len(), 2);
|
||||
h.query_by_label_contains("Lock all").expect("the Lock all button").click();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all;
|
||||
assert!(locked(extra) && locked(ids[1]), "both selected layers lock");
|
||||
assert!(!locked(ids[0]), "the unselected Background doesn't");
|
||||
}
|
||||
|
||||
/// The Basic layout's single lock toggle also locks every selected layer.
|
||||
#[test]
|
||||
fn the_basic_lock_toggle_locks_every_selected_layer() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::Studio);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("prefs.set", json!({"path": "interface.theme", "value": crate::theme::ThemeKind::Studio.id()})).unwrap();
|
||||
app.ui.theme = crate::theme::ThemeKind::Studio;
|
||||
app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
|
||||
app
|
||||
});
|
||||
let a = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
h.state_mut().run("layer.select", json!({"layer": a, "mode": "add"})).unwrap();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Lock layer").click();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all;
|
||||
assert!(locked(a) && locked(b), "both selected layers lock");
|
||||
}
|
||||
|
||||
/// The Background, selected with other layers, is left out of their change (Opacity here)
|
||||
/// until Photoshop's behaviour is confirmed (#5856): it stays the Background.
|
||||
#[test]
|
||||
fn a_selected_background_is_left_out_of_a_several_layer_change() {
|
||||
let mut h = app();
|
||||
let st = h.state().session.active().unwrap();
|
||||
let (bg, top) = (st.doc.layers[0].id.0, st.active_layer.unwrap().0);
|
||||
h.state_mut().run("layer.select", json!({"layer": bg})).unwrap();
|
||||
h.state_mut().run("layer.select", json!({"layer": top, "mode": "add"})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Lock all").click();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let l = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().clone();
|
||||
assert!(l(top).locks.all, "the layer locks");
|
||||
assert!(st.doc.is_background(photocraft_doc::LayerId(bg)) && !l(bg).locks.all, "the Background is left as it was");
|
||||
}
|
||||
@@ -1044,6 +1044,21 @@ impl PhotocraftApp {
|
||||
{
|
||||
authorize(id, ¶ms)?;
|
||||
}
|
||||
// A new layer while Free Transform is open applies the transform first, as Photoshop does
|
||||
// (#5667): History reads the transform (or Place Embedded), then New Layer. Only for a
|
||||
// command that can run; a transform refused or still being applied keeps the box open and
|
||||
// the command doesn't run.
|
||||
if photocraft_engine::PRECHECKED.contains(&id) && self.ui.transform.is_some() {
|
||||
// Bad params or a command that can't run leave the box as it is.
|
||||
self.session.precheck(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
transform_tool::commit(self);
|
||||
if transform_tool::committing(self).is_some() {
|
||||
return Err("the transform is still being applied; try again when it has finished".into());
|
||||
}
|
||||
if self.ui.transform.is_some() {
|
||||
return Err(if self.ui.status.is_empty() { "the transform couldn't be applied".into() } else { self.ui.status.clone() });
|
||||
}
|
||||
}
|
||||
if let Some(result) = panel_docking::command(self, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
@@ -2638,6 +2653,10 @@ mod pixel_aspect_tests;
|
||||
#[cfg(test)]
|
||||
mod transform_fx_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod layer_props_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod transform_switch_tests;
|
||||
#[cfg(test)]
|
||||
mod transform_undo_tests;
|
||||
|
||||
|
||||
@@ -1518,8 +1518,9 @@ const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W;
|
||||
|
||||
/// The `layer.setProps` edit of a Layers panel Opacity or Fill (`key`) field at `pct` percent. A
|
||||
/// drag of the pop-up slider (`drag`) coalesces into one history step, as in Photoshop.
|
||||
fn pct_action(l: &Layer, key: &str, pct: f32, drag: Option<u64>) -> (String, Value) {
|
||||
let mut p = json!({"layer": l.id.0, key: pct / 100.0});
|
||||
fn pct_action(target: &Value, key: &str, pct: f32, drag: Option<u64>) -> (String, Value) {
|
||||
let mut p = target.clone();
|
||||
p[key] = json!(pct / 100.0);
|
||||
if let Some(n) = drag {
|
||||
p["coalesce"] = json!(format!("layer-{key}-slider:{n}"));
|
||||
}
|
||||
@@ -2159,13 +2160,17 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
/// any is set, and locks all otherwise. Toggling only "lock all" would leave the Background's
|
||||
/// transparency lock (still showing as locked), and a second click would lock its pixels,
|
||||
/// shutting out the Eraser and every other paint tool (#76).
|
||||
fn simple_lock_toggle(background: bool, l: &Layer) -> (String, Value) {
|
||||
/// The Basic layout's lock toggle: the active layer's state decides, and every target follows it
|
||||
/// (`targets`: `{"layer": id}` or `{"layers": [ids]}`).
|
||||
fn simple_lock_toggle(background: bool, l: &Layer, targets: &Value) -> (String, Value) {
|
||||
if background {
|
||||
return ("layer.new.layerFromBackground".into(), json!({}));
|
||||
}
|
||||
let locks =
|
||||
if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) };
|
||||
("layer.setProps".into(), json!({"layer": l.id.0, "locks": locks}))
|
||||
let mut p = targets.clone();
|
||||
p["locks"] = locks;
|
||||
("layer.setProps".into(), p)
|
||||
}
|
||||
|
||||
/// The Layers panel's blend modes: Photoshop's, plus the layer's own Paint.NET mode when it has one
|
||||
@@ -2231,8 +2236,21 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
if let Some(l) = active_layer {
|
||||
// Photoshop greys blend mode, Opacity and Fill for the Background layer.
|
||||
// With several layers selected, blend mode, Opacity, Fill and the locks change every one
|
||||
// of them, as in Photoshop; the controls show the active layer's values.
|
||||
// The Background stays out of a several-layer change until Photoshop's behaviour is
|
||||
// confirmed (#5856): Opacity, Fill or a blend mode would turn it into Layer 0.
|
||||
let ids: Vec<LayerId> = if selection.len() > 1 && selection.contains(&l.id) {
|
||||
selection.iter().copied().filter(|id| *id == l.id || !doc.layer(*id).is_some_and(|x| crate::doc_props_ui::is_background(&doc, x))).collect()
|
||||
} else {
|
||||
vec![l.id]
|
||||
};
|
||||
let targets = if ids.len() > 1 { json!({"layers": ids.iter().map(|id| id.0).collect::<Vec<_>>()}) } else { json!({"layer": l.id.0}) };
|
||||
// Photoshop greys blend mode, Opacity and Fill for the Background layer. (With the
|
||||
// Background among several selected, what Photoshop does awaits a check: #5856.)
|
||||
let bg = crate::doc_props_ui::is_background(&doc, l);
|
||||
// Pass Through is a group's mode: offered only when every target is a group.
|
||||
let all_groups = ids.iter().all(|id| doc.layer(*id).is_some_and(|x| x.is_group()));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(!bg, |ui| {
|
||||
let mut m = l.blend;
|
||||
@@ -2242,13 +2260,15 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") };
|
||||
let (blend_width, show_opacity_label) =
|
||||
opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x);
|
||||
let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), blend_width);
|
||||
let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(all_groups, l.blend), blend_width);
|
||||
// One step per choice: a click, an arrow key or each wheel notch (#1747).
|
||||
for m in &chosen {
|
||||
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()})));
|
||||
let mut p = targets.clone();
|
||||
p["blend"] = json!(m.label());
|
||||
actions.push(("layer.setProps".into(), p));
|
||||
}
|
||||
// Hovering a mode previews it on the canvas (#970).
|
||||
crate::blend_preview::hover(app, l.id, hovered.filter(|_| chosen.is_empty()));
|
||||
crate::blend_preview::hover(app, &ids, hovered.filter(|_| chosen.is_empty()));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut o = l.opacity * 100.0;
|
||||
let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
|
||||
@@ -2259,7 +2279,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
widgets::PopupFieldResponse::default()
|
||||
};
|
||||
if field.changed || scrub.changed {
|
||||
actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag)));
|
||||
actions.push(pct_action(&targets, "opacity", o, scrub.drag.or(field.drag)));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2295,7 +2315,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// Lock Image Pixels includes Lock Transparent Pixels, which waits for it (#4892).
|
||||
let enabled = key != "transparency" || !lk.pixels;
|
||||
if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 20.0, on, tip)).inner.clicked() {
|
||||
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "locks": { key: !on }})));
|
||||
let mut p = targets.clone();
|
||||
p["locks"] = json!({ key: !on });
|
||||
actions.push(("layer.setProps".into(), p));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2303,7 +2325,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if !t.pro {
|
||||
let locked = l.locks.any();
|
||||
if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() {
|
||||
actions.push(simple_lock_toggle(bg, l));
|
||||
actions.push(simple_lock_toggle(crate::doc_props_ui::is_background(&doc, l), l, &targets));
|
||||
}
|
||||
}
|
||||
ui.add_enabled_ui(!bg, |ui| {
|
||||
@@ -2312,7 +2334,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W);
|
||||
let scrub = scrub_pct_label(ui, fill_label, &mut f, ui.available_width());
|
||||
if field.changed || scrub.changed {
|
||||
actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag)));
|
||||
actions.push(pct_action(&targets, "fill", f, scrub.drag.or(field.drag)));
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -2877,7 +2899,7 @@ fn layer_row(
|
||||
if let Some(&(_, lock)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Lock)
|
||||
&& ui.interact(lock, ui.id().with(("row-lock", l.id.0)), Sense::click()).clicked()
|
||||
{
|
||||
actions.push(simple_lock_toggle(doc.is_background(l.id), l));
|
||||
actions.push(simple_lock_toggle(doc.is_background(l.id), l, &json!({"layer": l.id.0})));
|
||||
}
|
||||
let name_color = if l.visible { t.text } else { t.text_faint };
|
||||
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
|
||||
@@ -3139,6 +3161,8 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let icon = match e.as_str() {
|
||||
s if s.contains("Brush") || s.contains("Eraser") => "brush",
|
||||
// The Layers panel's controls (layer.setProps).
|
||||
"Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers",
|
||||
s if s.contains("Layer") => "layers",
|
||||
s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed",
|
||||
s if s.contains("Fill") => "paint-bucket",
|
||||
@@ -4992,7 +5016,7 @@ mod lock_tests {
|
||||
fn click_lock(app: &mut PhotocraftApp) {
|
||||
let st = app.session.active().unwrap();
|
||||
let l = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap();
|
||||
let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l);
|
||||
let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l, &json!({"layer": l.id.0}));
|
||||
app.run(&cmd, p).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -804,6 +804,47 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec<String>,
|
||||
out
|
||||
}
|
||||
|
||||
/// A fingerprint of the bindings a shortcut conflict describes.
|
||||
fn conflict_state(overrides: &BTreeMap<String, String>, alternates: &BTreeMap<String, Vec<String>>, selected: &str, set: Option<&Value>) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(overrides, alternates, selected, set.map(Value::to_string)).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Keys the system takes before the app sees them (switching apps, closing the window, force
|
||||
/// quit): Edit › Keyboard Shortcuts won't assign them, as Photoshop won't. `sc` is in our
|
||||
/// notation, where `Cmd` is Ctrl off the Mac.
|
||||
pub fn reserved_shortcut(sc: &str) -> bool {
|
||||
let Some(n) = prefs::normalize_shortcut(sc) else { return false };
|
||||
let reserved: &[&str] = if cfg!(target_os = "macos") {
|
||||
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
|
||||
} else {
|
||||
// Linux and the BSDs: window switching and closing, and the virtual consoles.
|
||||
&[
|
||||
"Alt+Tab",
|
||||
"Alt+Shift+Tab",
|
||||
"Alt+F4",
|
||||
"Cmd+Alt+Delete",
|
||||
"Cmd+Alt+F1",
|
||||
"Cmd+Alt+F2",
|
||||
"Cmd+Alt+F3",
|
||||
"Cmd+Alt+F4",
|
||||
"Cmd+Alt+F5",
|
||||
"Cmd+Alt+F6",
|
||||
"Cmd+Alt+F7",
|
||||
"Cmd+Alt+F8",
|
||||
"Cmd+Alt+F9",
|
||||
"Cmd+Alt+F10",
|
||||
"Cmd+Alt+F11",
|
||||
"Cmd+Alt+F12",
|
||||
]
|
||||
};
|
||||
reserved.iter().filter_map(|r| prefs::normalize_shortcut(r)).any(|r| r == n)
|
||||
}
|
||||
|
||||
/// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers.
|
||||
pub fn shortcut_text(key: egui::Key, m: egui::Modifiers) -> Option<String> {
|
||||
if is_modifier_key(key) {
|
||||
@@ -1687,6 +1728,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
|
||||
let alternates_ok = parsed_alts.as_ref().is_none_or(Result::is_ok);
|
||||
let mut alt_overrides = parsed_alts.and_then(Result::ok).unwrap_or_default();
|
||||
let mut capture_alt = f.get("captureAlt").and_then(Value::as_bool).unwrap_or(false);
|
||||
// A key just assigned that another command holds: Photoshop's Accept and Go to Conflict /
|
||||
// Undo Changes (an object: the key, the other command, and this one's keys before).
|
||||
let mut conflict: Option<Value> = f.get("conflict").filter(|c| c.is_object()).cloned();
|
||||
let mut hidden: Vec<String> = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
let mut colors: BTreeMap<String, String> = f.get("colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
let mut selected = f.get("selected").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
@@ -1739,6 +1783,7 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
|
||||
});
|
||||
if key == egui::Key::Escape {
|
||||
capture = false;
|
||||
conflict = None;
|
||||
} else if matches!(key, egui::Key::Backspace | egui::Key::Delete) && m.is_none() {
|
||||
if capture_alt {
|
||||
alt_overrides.insert(selected.clone(), Vec::new());
|
||||
@@ -1748,8 +1793,12 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
|
||||
capture = false;
|
||||
message = tl!("Shortcut removed.").into();
|
||||
message_warning = false;
|
||||
} else if let Some(sc) = shortcut_text(key, m).filter(|sc| reserved_shortcut(sc)) {
|
||||
message = crate::i18n::fmt(tl!("{shortcut} is reserved by the system and can't be assigned."), &[("shortcut", &crate::shortcuts::pretty(&sc))]);
|
||||
message_warning = true;
|
||||
capture = false;
|
||||
} else if let Some(sc) = shortcut_text(key, m) {
|
||||
let clash: Vec<String> = items
|
||||
let clash_ids: Vec<&str> = items
|
||||
.iter()
|
||||
.filter(|(id, _, _, def)| {
|
||||
let cur = eff(&overrides, id, def);
|
||||
@@ -1757,11 +1806,30 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
|
||||
&& (cur.as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone())
|
||||
|| eff_alts(&alt_overrides, id, cur.as_deref()).contains(&sc))
|
||||
})
|
||||
.map(|(id, ..)| id.as_str())
|
||||
.collect();
|
||||
let clash: Vec<String> = items
|
||||
.iter()
|
||||
.filter(|(id, ..)| clash_ids.contains(&id.as_str()))
|
||||
.map(|(id, label, path, _)| {
|
||||
let (path, label) = shown(id, label, path);
|
||||
format!("{} › {}", path.join(" › "), label.trim_end_matches('…'))
|
||||
})
|
||||
.collect();
|
||||
// What Undo Changes puts back: the command's main key and further keys as they were.
|
||||
if let Some(first) = clash_ids.first() {
|
||||
conflict = Some(json!({
|
||||
"shortcut": sc,
|
||||
"with": first,
|
||||
// Every command holding the key: Accept takes it from all of them.
|
||||
"holders": clash_ids,
|
||||
"command": selected,
|
||||
"main": overrides.get(&selected),
|
||||
"alternates": alt_overrides.get(&selected),
|
||||
}));
|
||||
} else {
|
||||
conflict = None;
|
||||
}
|
||||
if capture_alt {
|
||||
let def = items.iter().find(|i| i.0 == selected).and_then(|i| i.3.clone());
|
||||
let cur = eff(&overrides, &selected, &def);
|
||||
@@ -1925,6 +1993,53 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
|
||||
if !message.is_empty() {
|
||||
ui.label(RichText::new(&message).color(if message_warning { t.warning } else { t.text_dim }));
|
||||
}
|
||||
// The conflict describes the bindings as the capture left them: any later change (Use
|
||||
// Default, Delete, ×, Reset All, another command or set) makes it stale, and it goes.
|
||||
let state = conflict_state(&overrides, &alt_overrides, &selected, f.get("activeSet"));
|
||||
if conflict.as_ref().and_then(|c| c.get("state")).is_some_and(|s| s.as_u64() != Some(state)) {
|
||||
conflict = None;
|
||||
}
|
||||
if tab == 0
|
||||
&& let Some(c) = conflict.clone()
|
||||
{
|
||||
let (key, with, command) =
|
||||
(c["shortcut"].as_str().unwrap_or_default(), c["with"].as_str().unwrap_or_default(), c["command"].as_str().unwrap_or_default());
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("Accept and Go to Conflict")).on_hover_text(tl!("Take the shortcut from the other command and select it")).clicked() {
|
||||
let holders: Vec<String> = serde_json::from_value(c["holders"].clone()).unwrap_or_else(|_| vec![with.to_string()]);
|
||||
for holder in &holders {
|
||||
let def = items.iter().find(|i| i.0 == *holder).and_then(|i| i.3.clone());
|
||||
let cur = eff(&overrides, holder, &def);
|
||||
if cur.as_deref().and_then(prefs::normalize_shortcut).as_deref() == Some(key) {
|
||||
overrides.insert(holder.clone(), String::new());
|
||||
}
|
||||
let keys = eff_alts(&alt_overrides, holder, eff(&overrides, holder, &def).as_deref());
|
||||
if keys.iter().any(|k| k == key) {
|
||||
alt_overrides.insert(holder.clone(), keys.into_iter().filter(|k| k != key).collect());
|
||||
}
|
||||
}
|
||||
selected = with.to_string();
|
||||
filter.clear();
|
||||
message = tl!("Choose a new shortcut for this command, or leave it without one.").into();
|
||||
message_warning = false;
|
||||
conflict = None;
|
||||
}
|
||||
if ui.button(tl!("Undo Changes")).on_hover_text(tl!("Put this command's shortcuts back as they were")).clicked() {
|
||||
match c["main"].as_str() {
|
||||
Some(m) => overrides.insert(command.to_string(), m.to_string()),
|
||||
None => overrides.remove(command),
|
||||
};
|
||||
match serde_json::from_value::<Vec<String>>(c["alternates"].clone()) {
|
||||
Ok(list) => alt_overrides.insert(command.to_string(), list),
|
||||
Err(_) => alt_overrides.remove(command),
|
||||
};
|
||||
selected = command.to_string();
|
||||
message = tl!("Changes undone.").into();
|
||||
message_warning = false;
|
||||
conflict = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
if overrides_ok {
|
||||
f.insert("overrides".into(), json!(overrides));
|
||||
}
|
||||
@@ -1932,6 +2047,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
|
||||
f.insert("alternates".into(), json!(alt_overrides));
|
||||
}
|
||||
f.insert("captureAlt".into(), json!(capture && capture_alt));
|
||||
// A conflict just found is stamped with the bindings it describes.
|
||||
if let Some(c) = conflict.as_mut().filter(|c| c.get("state").is_none()) {
|
||||
c["state"] = json!(conflict_state(&overrides, &alt_overrides, &selected, f.get("activeSet")));
|
||||
}
|
||||
f.insert("conflict".into(), conflict.unwrap_or(Value::Null));
|
||||
f.insert("hidden".into(), json!(hidden));
|
||||
f.insert("colors".into(), json!(colors));
|
||||
f.insert("selected".into(), json!(selected));
|
||||
|
||||
@@ -230,3 +230,119 @@ fn a_user_shortcut_wins_over_a_default_it_was_not_moved_from() {
|
||||
let bound: Vec<String> = crate::shortcut_dispatch::bindings(&app).into_iter().filter(|(_, sc)| *sc == f7).map(|(id, _)| id).collect();
|
||||
assert_eq!(bound, ["select.modify.feather"]);
|
||||
}
|
||||
|
||||
/// Capture `k` with `m` for the selected command.
|
||||
fn capture_key(h: &mut Harness<'_, PhotocraftApp>, id: u64, k: Key, m: Modifiers) {
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true));
|
||||
h.run_steps(2);
|
||||
key(h, k, true, m);
|
||||
h.run_steps(2);
|
||||
key(h, k, false, m);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, PhotocraftApp>, label: &str) {
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_offers_accept_and_go_to_conflict() {
|
||||
// Photoshop: the key is taken from Copy now, and Copy is selected to get a new one.
|
||||
let (mut h, id) = dialog(1.0, "edit.cut", "");
|
||||
capture_key(&mut h, id, Key::C, Modifiers::COMMAND);
|
||||
assert_eq!(field(&h, id, "overrides")["edit.cut"], json!("Cmd+C"));
|
||||
assert_eq!(field(&h, id, "conflict")["with"], json!("edit.copy"));
|
||||
click(&mut h, "Accept and Go to Conflict");
|
||||
assert_eq!(field(&h, id, "selected"), json!("edit.copy"));
|
||||
assert_eq!(field(&h, id, "overrides")["edit.copy"], json!(""), "Copy no longer holds Cmd+C");
|
||||
assert!(field(&h, id, "conflict").is_null());
|
||||
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||
assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.cut", Some("Cmd+X")).as_deref(), Some("Cmd+C"));
|
||||
assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_changes_puts_the_command_back() {
|
||||
let (mut h, id) = dialog(1.0, "edit.cut", "");
|
||||
capture_key(&mut h, id, Key::C, Modifiers::COMMAND);
|
||||
click(&mut h, "Undo Changes");
|
||||
assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides"));
|
||||
assert!(field(&h, id, "conflict").is_null());
|
||||
// An alternate added with Add Shortcut is undone the same way.
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("captureAlt".into(), json!(true));
|
||||
capture_key(&mut h, id, Key::C, Modifiers::COMMAND);
|
||||
assert_eq!(field(&h, id, "alternates")["edit.cut"], json!(["F2", "Cmd+C"]), "beside Photoshop's F2");
|
||||
click(&mut h, "Undo Changes");
|
||||
assert!(field(&h, id, "alternates").get("edit.cut").is_none(), "{}", field(&h, id, "alternates"));
|
||||
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||
assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_system_key_is_refused() {
|
||||
let (k, m) = if cfg!(target_os = "macos") { (Key::Tab, Modifiers::MAC_CMD | Modifiers::COMMAND) } else { (Key::F4, Modifiers::ALT) };
|
||||
let (mut h, id) = dialog(1.0, "edit.cut", "");
|
||||
capture_key(&mut h, id, k, m);
|
||||
assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides"));
|
||||
assert!(field(&h, id, "message").as_str().is_some_and(|s| s.contains("reserved")), "{}", field(&h, id, "message"));
|
||||
assert_eq!(field(&h, id, "messageWarning"), json!(true));
|
||||
assert!(crate::prefs_ui::reserved_shortcut(if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" }));
|
||||
assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Shift+F4"));
|
||||
assert!(!crate::prefs_ui::reserved_shortcut("garbage+"));
|
||||
}
|
||||
|
||||
/// The conflict describes the bindings as the capture left them: Reset All, Use Default or
|
||||
/// Escape after it makes it stale, so Accept can't take a key nothing holds any more.
|
||||
#[test]
|
||||
fn a_conflict_goes_stale_when_the_bindings_change() {
|
||||
for undo in ["Reset All to Defaults", "Use Default", "escape"] {
|
||||
let (mut h, id) = dialog(1.0, "edit.cut", "");
|
||||
capture_key(&mut h, id, Key::C, Modifiers::COMMAND);
|
||||
assert!(field(&h, id, "conflict").is_object(), "{undo}");
|
||||
if undo == "escape" {
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true));
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Escape, true, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Escape, false, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
} else {
|
||||
click(&mut h, undo);
|
||||
}
|
||||
assert!(field(&h, id, "conflict").is_null(), "{undo}: {}", field(&h, id, "conflict"));
|
||||
assert!(h.query_by_label("Accept and Go to Conflict").is_none(), "{undo}");
|
||||
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||
if undo != "escape" {
|
||||
assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C"), "{undo}: Copy keeps ⌘C");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn linux_reserves_its_own_system_keys() {
|
||||
assert!(crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2"));
|
||||
assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Escape"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_reserves_its_own_system_keys() {
|
||||
assert!(crate::prefs_ui::reserved_shortcut("Cmd+Escape"));
|
||||
assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2"));
|
||||
}
|
||||
|
||||
/// A key two commands hold (Cut's ⌘X, given to Copy too): Accept takes it from both.
|
||||
#[test]
|
||||
fn accept_takes_the_key_from_every_command_holding_it() {
|
||||
let (mut h, id) = dialog(1.0, "edit.paste", "");
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({"edit.copy": "Cmd+X"}));
|
||||
h.run_steps(2);
|
||||
capture_key(&mut h, id, Key::X, Modifiers::COMMAND);
|
||||
let holders = field(&h, id, "conflict")["holders"].clone();
|
||||
assert!(holders.as_array().is_some_and(|a| a.len() == 2), "{holders}");
|
||||
click(&mut h, "Accept and Go to Conflict");
|
||||
let o = field(&h, id, "overrides");
|
||||
assert_eq!((o["edit.cut"].clone(), o["edit.copy"].clone(), o["edit.paste"].clone()), (json!(""), json!(""), json!("Cmd+X")), "{o}");
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Free Transform with the whole app: picking another tool or layer, or New Layer, while the box
|
||||
//! is open applies the transform as it stands, as Photoshop does (#5441, #5665, #5667).
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// The app with a 400 × 300 document: a red 100 × 60 rectangle at (100, 100) on its own layer
|
||||
/// above the Background.
|
||||
fn app() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
/// Open Free Transform and move the box 50 px right, 40 px down.
|
||||
fn transform_and_move(h: &mut Harness<'static, PhotocraftApp>) {
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
|
||||
for p in &mut t.quad {
|
||||
p[0] += 50.0;
|
||||
p[1] += 40.0;
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn red_bounds(h: &Harness<'static, PhotocraftApp>) -> photocraft_geom::Rect {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let top = st.doc.layers.last().unwrap();
|
||||
top.surface().unwrap().content_bounds()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_another_tool_in_the_toolbar_applies_the_transform() {
|
||||
let mut h = app();
|
||||
transform_and_move(&mut h);
|
||||
let hand = h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool");
|
||||
hand.click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none(), "the box closed");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "the move was applied, not cancelled");
|
||||
}
|
||||
|
||||
/// The app with the red rectangle on a layer the last step made (Layer via Copy, which deselects in
|
||||
/// the same step), as Place Embedded leaves it for the box `begin_placed` opens: cancelling the
|
||||
/// place takes that step back.
|
||||
fn placed_app() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("layer.new.layerViaCopy", json!({})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_none(), "Layer via Copy deselects in its own step");
|
||||
h
|
||||
}
|
||||
|
||||
fn history(h: &Harness<'static, PhotocraftApp>) -> Vec<String> {
|
||||
h.state().session.active().unwrap().history.entries().to_vec()
|
||||
}
|
||||
|
||||
/// The box on a just-placed layer (a file dropped on the canvas), moved.
|
||||
fn placed_and_moved(h: &mut Harness<'static, PhotocraftApp>) {
|
||||
let ctx = h.ctx.clone();
|
||||
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
|
||||
h.run_steps(2);
|
||||
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
|
||||
for p in &mut t.quad {
|
||||
p[0] += 50.0;
|
||||
p[1] += 40.0;
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_image_is_applied_when_another_tool_is_picked() {
|
||||
let mut h = placed_app();
|
||||
placed_and_moved(&mut h);
|
||||
h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none());
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_another_layer_in_the_panel_applies_the_transform() {
|
||||
for placed in [false, true] {
|
||||
let mut h = if placed { placed_app() } else { app() };
|
||||
if placed {
|
||||
placed_and_moved(&mut h);
|
||||
} else {
|
||||
transform_and_move(&mut h);
|
||||
}
|
||||
h.get_by_label("Background").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none(), "placed {placed}");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "placed {placed}");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layer_applies_the_transform_first() {
|
||||
for placed in [false, true] {
|
||||
let mut h = if placed { placed_app() } else { app() };
|
||||
let before = history(&h);
|
||||
if placed {
|
||||
placed_and_moved(&mut h);
|
||||
} else {
|
||||
transform_and_move(&mut h);
|
||||
}
|
||||
// The Layers panel's New Layer button (the menu item opens the New Layer dialog).
|
||||
h.get_by_label("Create a new layer").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none(), "placed {placed}");
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 3, "placed {placed}");
|
||||
let red = st.doc.layers[1].surface().unwrap().content_bounds();
|
||||
assert_eq!(red, photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}: the moved image, not the original");
|
||||
// History: the transform (folded into Place Embedded for a placed layer), then New Layer.
|
||||
let after = history(&h);
|
||||
let added: Vec<&str> = after.iter().skip(before.len().saturating_sub(usize::from(placed))).map(String::as_str).collect();
|
||||
let first = if placed { photocraft_engine::file_cmds::PLACE_EMBEDDED } else { "Free Transform" };
|
||||
assert_eq!(added, [first, "New Layer"], "placed {placed}: {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_placed_image_stays_when_another_tool_or_layer_is_picked() {
|
||||
for via_layer in [false, true] {
|
||||
let mut h = placed_app();
|
||||
let ctx = h.ctx.clone();
|
||||
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
|
||||
h.run_steps(2);
|
||||
if via_layer {
|
||||
let bg = h.state().session.active().unwrap().doc.layers[0].id;
|
||||
h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
} else {
|
||||
h.query_by_label_contains("Hand Tool").expect("Hand Tool").click();
|
||||
}
|
||||
h.run_steps(4);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 2, "via layer {via_layer}: the placed image stays");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160));
|
||||
}
|
||||
}
|
||||
|
||||
/// The placed fixture is a real place: cancelling the box takes the layer back, so the tests
|
||||
/// above would see a switch that cancelled it.
|
||||
#[test]
|
||||
fn cancelling_the_placed_fixture_removes_its_layer() {
|
||||
let mut h = placed_app();
|
||||
placed_and_moved(&mut h);
|
||||
crate::transform_tool::cancel(h.state_mut());
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1);
|
||||
}
|
||||
|
||||
/// A new-layer command that can't run (Artboard from Group with a pixel layer active) leaves the box and the
|
||||
/// placed layer as they were.
|
||||
#[test]
|
||||
fn a_new_layer_command_that_cant_run_leaves_the_box_open() {
|
||||
let mut h = placed_app();
|
||||
placed_and_moved(&mut h);
|
||||
let before = history(&h);
|
||||
assert!(h.state_mut().run("layer.new.artboardFromGroup", json!({})).is_err());
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.transform.is_some(), "the box stays open");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays");
|
||||
assert_eq!(history(&h), before);
|
||||
}
|
||||
|
||||
/// A transform the engine refuses (a result far over the pixel budget, #3773) keeps the box open
|
||||
/// with the placed layer, and the new layer isn't made: the refusal comes back.
|
||||
#[test]
|
||||
fn a_refused_transform_keeps_the_placed_layer_and_refuses_the_new_layer() {
|
||||
let mut h = placed_app();
|
||||
placed_and_moved(&mut h);
|
||||
let t = h.state_mut().ui.transform.as_mut().unwrap();
|
||||
t.quad = [[0.0, 0.0], [4.0e6, 0.0], [4.0e6, 4.0e6], [0.0, 4.0e6]];
|
||||
h.run_steps(2);
|
||||
let before = history(&h);
|
||||
let r = h.state_mut().run("layer.new.layer", json!({}));
|
||||
h.run_steps(2);
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
assert!(h.state().ui.transform.is_some(), "the box stays open");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays, no new layer");
|
||||
assert_eq!(history(&h), before);
|
||||
}
|
||||
|
||||
/// New Layer with bad params, while a moved box is open, is refused before the transform is
|
||||
/// applied: the box, the layer and History stay as they were.
|
||||
#[test]
|
||||
fn new_layer_with_bad_params_leaves_the_box_open() {
|
||||
for bad in [json!({"mode": "Bogus"}), json!({"fillNeutral": true}), json!({"position": "x"}), json!([1])] {
|
||||
let mut h = app();
|
||||
transform_and_move(&mut h);
|
||||
let before = history(&h);
|
||||
assert!(h.state_mut().run("layer.new.layer", bad.clone()).is_err(), "{bad}");
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.transform.is_some(), "{bad}: the box stays open");
|
||||
assert_eq!(history(&h), before, "{bad}: nothing applied");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -1288,6 +1288,9 @@ fn font_styles_keep_metadata_names_and_numeric_labels() {
|
||||
|
||||
#[test]
|
||||
fn postscript_only_style_shows_actual_subfamily() {
|
||||
// selected_style reads the engine only once the font scan is done (try_shared). Under nextest
|
||||
// this test runs in a process of its own, where nothing else has started the scan, so start it.
|
||||
let _ = photocraft_text::shared();
|
||||
let style = photocraft_doc::text::CharStyle { font_family: "Inter".into(), postscript_name: Some("Inter-SemiBold".into()), ..Default::default() };
|
||||
assert_eq!(super::selected_style(&style), "SemiBold");
|
||||
}
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ cargo nextest run --workspace # parallel across binaries (cargo ins
|
||||
cargo test --doc --workspace # nextest does not run doctests
|
||||
```
|
||||
|
||||
[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI still runs `cargo test`, and a full `cargo test` stays the final check before a PR.
|
||||
[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR.
|
||||
|
||||
## Fonts (craft-fonts)
|
||||
|
||||
|
||||
Reference in new issue
Block a user