Compare commits

...
Author SHA1 Message Date
Tanish Manku d9e1c10c30 Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines) 2026-10-15 15:22:09 -07:00
Tanish Manku 4a1acca879 Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute 2026-10-15 15:15:11 -07:00
Tanish Manku a95b3f0a85 Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied 2026-10-15 15:15:11 -07:00
Tanish Manku b7eab1a377 Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal 2026-10-15 15:15:11 -07:00
Tanish Manku 25a8200110 New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order 2026-10-15 15:15:10 -07:00
Tanish Manku 6443f5fb4c Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)
Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.
2026-10-15 15:15:01 -07:00
skylar taylor-barrick af54f4aed1 ci: limit the font pass to font-consuming crates (#6127)
Keep the full workspace and feature build selection while running the craft-fonts pass only for text, io and ui-egui. Ordinary workspace test coverage remains in the first test pass.
2026-10-15 16:03:39 -06:00
skylar taylor-barrick 1a4167936e ci: run tests with cargo nextest (#6118)
Run workspace tests with pinned cargo-nextest, a CI fail-fast profile and a ten-minute per-test hang limit. Keep cheap checks first and doctests in a separate step so failures propagate on Windows.
2026-10-15 16:02:57 -06:00
Tanish Manku 54f4baf6f9 Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names (#5856)
* Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names

With several layers selected, the Layers panel's blend mode, Opacity, Fill and lock controls
changed only the active layer, silently (the layers audit's top panel gap). They now change
every selected layer, as in Photoshop, in one undo step. layer.setProps takes
"layers": [id, …] for that (headless too); a list with an unknown id, a bad shape, or a
name for several layers is an error that changes nothing.

History names follow Photoshop for a single kind of change: Opacity Change, Fill Opacity
Change, Blending Change, Rename Layer (a mix stays Layer Properties).

* Layers panel with several selected: Pass Through only for groups (refused for other layers), the Basic lock toggle and the blend-mode hover preview take every selected layer, the controls grey with the Background selected; History icon for opacity/fill/blend changes

* Layers panel: leave the Background-in-a-selection behaviour as it was until Photoshop's is confirmed (#5856)

* Layers panel: a selected Background is left out of several-layer Opacity, Fill, blend and lock changes until Photoshop's behaviour is confirmed (#5856)
2026-10-15 14:33:51 -07:00
Tanish Manku 3cd0f6c954 Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a) (#5349)
* Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a)

A key that another command holds now offers Photoshop's two answers under the warning.
Accept and Go to Conflict takes the key from the other command at once (its main key or
an alternate) and selects it for a new one. Undo Changes puts the edited command's main and
further keys back as they were. OK without either still accepts, as in Photoshop.

Keys the system takes before the app sees them are refused with a message: Alt+Tab,
Alt+F4, Ctrl+Alt+Delete, Ctrl+Esc and Ctrl+Shift+Esc off the Mac; Cmd+Tab, Cmd+` and
Cmd+Opt+Esc on it.

* Keyboard Shortcuts: a conflict goes stale when the bindings change after it (Reset All, Use Default, Delete, ×, Escape, a set switch); Accept takes the key from every holder; Linux reserves its own keys; doc comment back on shortcut_text
2026-10-15 14:18:14 -07:00
Tanish Manku 04db697b5d Paste Layer Style: a group's Pass Through pastes onto other layers as Normal; style clipboard comment (#6310) 2026-10-15 14:17:22 -07:00
skylartaylor d68a1cbc2d ci: preserve separate nextest and doctest results 2026-10-15 19:56:43 +02:00
skylartaylor bcf2cefd4b ci: preserve nextest failures on Windows 2026-10-15 19:56:43 +02:00
skylartaylor 4f6e617555 ci: retain the font filter with integrated nextest checks 2026-10-15 19:44:21 +02:00
skylartaylor 40a7c20430 ci: integrate nextest with early checks and runtime limits 2026-10-15 19:44:21 +02:00
David MannandClaude Opus 5.5 caa28cb7ab CI: run only the font tests in the craft-fonts pass (#6120)
The pass reran every test in the workspace, but CRAFT_FONTS is only read in photocraft-text,
photocraft-io (svg) and photocraft-ui-egui. The build selection stays the same (the disk warning
above still applies); a nextest filter runs those three packages' tests: ~3,800 of ~8,900.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:41:12 +02:00
David MannandClaude Opus 5.5 e22c4ec7ab CI: run the tests with cargo nextest (#6117)
cargo test runs the ~258 test binaries one at a time; nextest runs them in parallel. Its new `ci`
profile stops at the first failure (a red PR frees its runner early), fails a test that hangs for
10 minutes instead of letting it hold the runner, and repeats every failure at the end of the log;
the GPU test group still applies. Same --workspace --locked --features heif selection, so the build
is unchanged. Doctests stay on cargo test --doc.

postscript_only_style_shows_actual_subfamily relied on another test in the same process having
started the font scan; under nextest it runs alone, so it starts the scan itself (as #2933 does).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:40:15 +02:00
41 changed files with 1171 additions and 118 deletions

No files matched your search

+10 -1
View File
@@ -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"
+18 -3
View File
@@ -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
+79 -40
View File
@@ -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)
}
+7 -1
View File
@@ -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();
+1 -11
View File
@@ -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, &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) = crate::hidden_target::refusal(self, id, &params) {
return Err(EngineError::Other(why.into()));
}
let run_params = self.admit(id, spec, &params)?;
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;
+9 -1
View File
@@ -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",
+55 -2
View File
@@ -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)
+41 -23
View File
@@ -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);
}
+74
View File
@@ -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:#?}");
}
+16
View File
@@ -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);
}
+31 -15
View File
@@ -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
}
+19 -5
View File
@@ -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);
}
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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");
}
+19
View File
@@ -1044,6 +1044,21 @@ impl PhotocraftApp {
{
authorize(id, &params)?;
}
// 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, &params).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, &params) {
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;
+38 -14
View File
@@ -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();
}
+121 -1
View File
@@ -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}");
}
}
+3
View File
@@ -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
View File
@@ -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)