mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 01:12:03 +00:00
Merge pull request #11278 from davidmann4/integration-batch-16
Integration batch 16: 9 epic PRs on main
This commit is contained in:
69 files changed
+6150
-188
No files matched your search
@@ -370,11 +370,13 @@ pub struct UiSetParams {
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
|
||||
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
|
||||
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "effect",
|
||||
/// "mode", "color", "smartObject" or "artboard", which lists the artboards that hold the
|
||||
/// selected layers), kinds (a list of layer kinds), name (matches part of the layer name),
|
||||
/// effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay",
|
||||
/// "outerGlow" or "dropShadow"), mode (a blend mode name), color (a layer colour label, or
|
||||
/// "none") and smart (a list of "linked", "missing" and "embedded") for the Layers panel
|
||||
/// "mode", "attribute", "color", "smartObject" or "artboard", which lists the artboards that
|
||||
/// hold the selected layers), kinds (a list of layer kinds), name (matches part of the layer
|
||||
/// name), effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay",
|
||||
/// "outerGlow" or "dropShadow"), mode (a blend mode name), attribute ("visible", "locked",
|
||||
/// "empty", "linked", "clipped", "layerMask", "vectorMask", "layerEffects",
|
||||
/// "advancedBlending", or "notVisible" … "noAdvancedBlending"), color (a layer colour label,
|
||||
/// or "none") and smart (a list of "linked", "missing" and "embedded") for the Layers panel
|
||||
/// filter). strokeTrace (true, false, "stderr" or "off": the stroke latency trace at runtime; a
|
||||
/// CSV file is set at launch with --stroke-trace, not over this channel). updatesOpen (bool,
|
||||
/// the Help › Check for Updates window).
|
||||
|
||||
@@ -575,6 +575,8 @@ fn build() -> Vec<CommandSpec> {
|
||||
dup.name.clone_from(name);
|
||||
}
|
||||
let nid = doc.insert_above(Some(id), dup);
|
||||
// A lone copy joins its original's link group, as in the reference application (#11217).
|
||||
crate::layer_multi_cmds::relink_copies(doc, &[nid])?;
|
||||
if !in_place {
|
||||
crate::artboard_cmds::place_copy(doc, nid)?;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
//! `edit.transform.againCopy`) into an action. A replayable command's journal entry replaces the
|
||||
//! entries of the commands it ran on its own behalf, so they are recorded once (#3067). Playback
|
||||
//! runs those steps with [`Session::execute`] and stops at the first error, leaving one history
|
||||
//! step per step that ran. Steps switched off with `actions.toggle` (the Actions panel check
|
||||
//! marks) are skipped by playback, Batch and droplets.
|
||||
//! entry for the whole play by default (`actions.setHistory` opts into per-step history).
|
||||
//! Steps switched off with `actions.toggle` (the Actions panel check marks) are skipped by
|
||||
//! playback, Batch and droplets.
|
||||
//!
|
||||
//! An untrusted session installs [`Session::authorize`]. `actions.play` calls it for every nested
|
||||
//! step, because the outer `actions.play` id would otherwise hide a recorded `file.*` command from
|
||||
@@ -22,6 +23,11 @@ use serde_json::{Value, json};
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
mod history;
|
||||
pub(crate) use history::ActionHistory;
|
||||
pub use history::{ActionHistoryContinuation, finish_pending_history, suspend_history};
|
||||
pub(crate) use history::{before_edit as before_action_edit, begin_document_history, finish_document_history, history_rows, reconcile_history_removal};
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
@@ -565,6 +571,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
|
||||
pub fn begin_playback(s: &mut Session, action: &Action) -> Result<Option<(usize, usize)>> {
|
||||
flush_recording(s)?;
|
||||
if s.actions.playing == 0 {
|
||||
history::begin(s, &action.name);
|
||||
s.actions.playback_steps = 0;
|
||||
}
|
||||
let recording = s.actions.recording.take();
|
||||
@@ -573,9 +580,13 @@ pub fn begin_playback(s: &mut Session, action: &Action) -> Result<Option<(usize,
|
||||
Ok(recording)
|
||||
}
|
||||
|
||||
/// Unwind the playback stack and finish grouping only at the outer boundary, then restore suspended recording.
|
||||
pub fn finish_playback(s: &mut Session, recording: Option<(usize, usize)>, action: &Action, from: usize) {
|
||||
s.actions.playback_stack.pop();
|
||||
s.actions.playing = s.actions.playing.saturating_sub(1);
|
||||
if s.actions.playing == 0 {
|
||||
history::finish_playback(s);
|
||||
}
|
||||
if let Some((_, idx)) = recording {
|
||||
restore_recording(s, idx, &action.name, from);
|
||||
}
|
||||
@@ -827,8 +838,34 @@ fn set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"action": name, "color": color}))
|
||||
}
|
||||
|
||||
/// Active playback and retained dialog continuations keep their chosen history policy.
|
||||
pub(crate) fn history_policy_in_use(s: &Session) -> bool {
|
||||
s.actions.playing != 0 || s.action_history.is_some() || s.paused_action_history.iter().any(|lease| lease.strong_count() != 0)
|
||||
}
|
||||
|
||||
/// Kept separate from Playback Options' performance modes until #5212 provides that dialog.
|
||||
fn set_history(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
not_playing(s, "actions.setHistory")?;
|
||||
if history_policy_in_use(s) {
|
||||
return Err(bad("actions.setHistory", "cannot change action history settings during playback"));
|
||||
}
|
||||
let enabled = p.get("singleState").and_then(Value::as_bool).ok_or_else(|| bad("actions.setHistory", "singleState must be a boolean"))?;
|
||||
s.edit_prefs(|prefs| prefs.actions.create_single_history_state = enabled);
|
||||
Ok(json!({"singleState":enabled}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "actions.setHistory",
|
||||
label: "Set Actions History",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"singleState":bool} → {singleState}. Persist Create a single history state (default true). Playback Options can use this setting without changing its performance mode."##,
|
||||
enabled: always,
|
||||
run: set_history,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "actions.move",
|
||||
label: "Move Action or Step",
|
||||
@@ -884,7 +921,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Play Action",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"action":name|index, "from":step?} → {action, ran, failed?:{step, id, error}}. step and from are 0-based; steps switched off with actions.toggle are skipped. Stops at the first error (the command still returns ok, with failed set) and leaves one history step per step that ran. Nested calls are supported up to 16 levels; cycles are rejected. Recording stores a named call rather than its expanded commands. Each step is checked with Session::authorize when one is installed."##,
|
||||
params: r##"{"action":name|index, "from":step?} → {action, ran, failed?:{step, id, error}}. step and from are 0-based; steps switched off with actions.toggle are skipped. Stops at the first error (the command still returns ok, with failed set) and groups document edits in one Action: <name> history entry by default; actions.setHistory can retain per-step history. Nested calls are supported up to 16 levels; cycles are rejected. Recording stores a named call rather than its expanded commands. Each step is checked with Session::authorize when one is installed."##,
|
||||
enabled: always,
|
||||
run: play,
|
||||
journal: false,
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Consolidate document history after commands finish their own atomic transactions.
|
||||
use super::{EngineError, Result, Session};
|
||||
use photocraft_doc::{DocId, Document};
|
||||
use photocraft_ops::{History, LayerTarget};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct BeforeDocument {
|
||||
id: DocId,
|
||||
doc: Arc<Document>,
|
||||
history: History,
|
||||
layers: LayerTarget,
|
||||
coalesce: Option<String>,
|
||||
saved_revision: u64,
|
||||
clean: bool,
|
||||
}
|
||||
|
||||
impl BeforeDocument {
|
||||
/// Capture the complete document boundary, including clean state and targeted layers.
|
||||
fn capture(st: &crate::DocState) -> Self {
|
||||
Self {
|
||||
id: st.doc.id,
|
||||
doc: st.doc.clone(),
|
||||
history: st.history.clone(),
|
||||
layers: st.layer_target(),
|
||||
coalesce: st.coalesce.clone(),
|
||||
saved_revision: st.saved_revision,
|
||||
clean: !st.is_dirty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ActionHistory {
|
||||
pub name: String,
|
||||
external: bool,
|
||||
before: Vec<BeforeDocument>,
|
||||
}
|
||||
|
||||
/// The outer play owns one lazy scope; nested plays share its document snapshots.
|
||||
pub(super) fn begin(s: &mut Session, name: &str) {
|
||||
if s.action_history.is_none() && s.prefs().actions.create_single_history_state {
|
||||
s.action_history = Some(ActionHistory { name: name.into(), external: false, before: Vec::new() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture each document lazily, before its first ordinary or compound edit. Commands retain
|
||||
/// their own records, purge and rollback semantics until the outer action finishes.
|
||||
pub(crate) fn before_edit(s: &mut Session) {
|
||||
let Some(group) = &s.action_history else { return };
|
||||
let Some(st) = s.active() else { return };
|
||||
let previous = group.before.iter().position(|before| before.id == st.doc.id);
|
||||
if previous.is_some_and(|i| !st.history.is_cleared() && !st.history.was_purged_since(&group.before[i].history)) {
|
||||
return;
|
||||
}
|
||||
let before = BeforeDocument::capture(st);
|
||||
if let Some(group) = &mut s.action_history {
|
||||
match previous {
|
||||
Some(i) => group.before[i] = before,
|
||||
None => group.before.push(before),
|
||||
}
|
||||
}
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.coalesce = None;
|
||||
st.history.begin_visibility_capture(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows visible just before the user deletes history, distinct from internal compound purges.
|
||||
pub(crate) fn history_rows(s: &Session) -> Vec<u64> {
|
||||
if s.action_history.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(st) = s.active() else { return Vec::new() };
|
||||
st.history.row_ids().collect()
|
||||
}
|
||||
|
||||
/// Apply only the user's actual row removals to the existing action boundary immediately.
|
||||
pub(crate) fn reconcile_history_removal(s: &mut Session, rows: &[u64]) {
|
||||
let Some(st) = s.active() else { return };
|
||||
let Some(group) = &s.action_history else { return };
|
||||
let Some(index) = group.before.iter().position(|before| before.id == st.doc.id) else { return };
|
||||
let prior = &group.before[index].history;
|
||||
let after: HashSet<_> = st.history.row_ids().collect();
|
||||
let baseline: HashSet<_> = prior.row_ids().collect();
|
||||
let removed: Vec<_> = rows.iter().copied().filter(|id| !after.contains(id) && baseline.contains(id)).collect();
|
||||
if removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
if prior.row_id(prior.past_len()).is_some_and(|id| removed.contains(&id)) {
|
||||
// The original pixels themselves were deleted. Only the post-delete document may
|
||||
// start a later grouped suffix; its accumulator excludes edits before that boundary.
|
||||
let mut before = BeforeDocument::capture(st);
|
||||
before.history.end_visibility_capture();
|
||||
if let Some(group) = &mut s.action_history {
|
||||
group.before[index] = before;
|
||||
}
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.coalesce = None;
|
||||
st.history.begin_visibility_capture(&[]);
|
||||
}
|
||||
} else if let Some(group) = &mut s.action_history {
|
||||
let history = &mut group.before[index].history;
|
||||
for id in removed {
|
||||
let Some(row) = history.row_of(id) else { continue };
|
||||
let current = history.past_len();
|
||||
if row < current {
|
||||
history.remove(row);
|
||||
} else if row > current {
|
||||
history.remove_later(row - current - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold ordinary edits while retaining real Clear History and irreversible purge boundaries.
|
||||
fn consolidate(s: &mut Session, group: &ActionHistory) {
|
||||
for before in &group.before {
|
||||
let Some(st) = s.docs.iter_mut().find(|st| st.doc.id == before.id) else { continue };
|
||||
if st.history.was_purged_since(&before.history) {
|
||||
st.history.end_visibility_capture();
|
||||
continue;
|
||||
}
|
||||
let mut history = before.history.clone();
|
||||
// Discarded command-local rows may have been visible while a dialog was open. Their
|
||||
// identifiers must not be allocated to a different state after consolidation.
|
||||
history.reserve_ids_from(&st.history);
|
||||
history.max_states = st.history.max_states;
|
||||
history.max_bytes = st.history.max_bytes;
|
||||
history.non_linear = st.history.non_linear;
|
||||
if st.doc.as_ref() == before.doc.as_ref() {
|
||||
st.doc = before.doc.clone();
|
||||
st.coalesce = before.coalesce.clone();
|
||||
if before.clean && st.saved_revision == before.saved_revision {
|
||||
st.saved_revision = st.revision;
|
||||
}
|
||||
} else {
|
||||
let visibility = st.history.captured_visibility().unwrap_or(st.history.current_visibility()).to_vec();
|
||||
history.set_current_layers(before.layers.clone());
|
||||
history.record(format!("Action: {}", group.name), before.doc.clone(), st.layer_target());
|
||||
history.add_current_visibility(&visibility);
|
||||
st.coalesce = None;
|
||||
}
|
||||
history.preserve_action_clear(&st.history);
|
||||
if let Some(source) = history.preserve_action_brush_source(&st.history) {
|
||||
let (source, restore_on_clear_undo) = match source {
|
||||
photocraft_ops::ActionBrushSource::Current(source) => (source, false),
|
||||
photocraft_ops::ActionBrushSource::Cleared(source) => (source, true),
|
||||
};
|
||||
// The selected row will no longer exist, but the History Brush must still paint its
|
||||
// exact pixels. Snapshots already retain source pixels without adding an Undo row.
|
||||
let id = st.next_snapshot;
|
||||
st.next_snapshot = id.saturating_add(1);
|
||||
st.snapshots.push(crate::history_cmds::Snapshot { id, name: format!("{}: {}", group.name, source.label), doc: source.doc, layers: source.layers });
|
||||
if restore_on_clear_undo {
|
||||
history.set_cleared_brush_snapshot(id);
|
||||
} else {
|
||||
history.set_action_brush_snapshot(id);
|
||||
}
|
||||
}
|
||||
st.history = history;
|
||||
st.trim_history();
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish all edited documents once the outer play, including its failure prefix, ends.
|
||||
pub(super) fn finish(s: &mut Session) {
|
||||
if let Some(group) = s.action_history.take() {
|
||||
consolidate(s, &group);
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch owns its scope across nested plays without adding an artificial playback depth.
|
||||
pub(crate) fn begin_document_history(s: &mut Session, name: &str) {
|
||||
begin(s, name);
|
||||
if let Some(group) = &mut s.action_history {
|
||||
group.external = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize the per-document scope explicitly owned by Batch, including a completed prefix on failure.
|
||||
pub(crate) fn finish_document_history(s: &mut Session) {
|
||||
finish(s);
|
||||
}
|
||||
|
||||
/// Finish an ordinary outer playback without closing a scope owned by the surrounding Batch.
|
||||
pub(super) fn finish_playback(s: &mut Session) {
|
||||
if s.action_history.as_ref().is_none_or(|group| !group.external) {
|
||||
finish(s);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct HistoryBinding {
|
||||
past: usize,
|
||||
rows: Vec<(Option<u64>, String)>,
|
||||
source: Option<usize>,
|
||||
source_snapshot: Option<u64>,
|
||||
cleared: bool,
|
||||
}
|
||||
|
||||
impl HistoryBinding {
|
||||
/// Rows and source identity detect history changes that leave document pixels untouched.
|
||||
fn of(history: &History) -> Self {
|
||||
let labels = history.entries().into_iter().chain(history.redo_labels().map(str::to_string));
|
||||
Self {
|
||||
past: history.past_len(),
|
||||
rows: labels.enumerate().map(|(i, label)| (history.row_id(i), label)).collect(),
|
||||
source: history.brush_source(),
|
||||
source_snapshot: history.brush_source_snapshot(),
|
||||
cleared: history.is_cleared(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PausedDocument {
|
||||
id: DocId,
|
||||
doc: Arc<Document>,
|
||||
history: HistoryBinding,
|
||||
source_snapshot: Option<u64>,
|
||||
}
|
||||
|
||||
/// Exact document/history binding of a dialog continuation. Never stored in dialog JSON.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ActionHistoryContinuation {
|
||||
_policy_lease: Arc<()>,
|
||||
/// Set by the first successful `resume`, shared by every clone: a kept clone can't reopen a
|
||||
/// scope that already ran (and may have finished).
|
||||
resumed: Arc<AtomicBool>,
|
||||
group: Option<ActionHistory>,
|
||||
active: Option<DocId>,
|
||||
documents: Vec<PausedDocument>,
|
||||
}
|
||||
|
||||
impl ActionHistoryContinuation {
|
||||
/// Resume before the dialog's edit; intervening edits must never join the original play.
|
||||
pub fn resume(&self, s: &mut Session) -> Result<()> {
|
||||
if s.action_history.is_some() || s.active().map(|st| st.doc.id) != self.active {
|
||||
return Err(EngineError::Other("the paused action's document is no longer current".into()));
|
||||
}
|
||||
for before in &self.documents {
|
||||
let Some(st) = s.docs.iter().find(|st| st.doc.id == before.id) else {
|
||||
return Err(EngineError::Other("a paused action's document was closed".into()));
|
||||
};
|
||||
if !Arc::ptr_eq(&st.doc, &before.doc) || HistoryBinding::of(&st.history) != before.history || st.source_snapshot != before.source_snapshot {
|
||||
return Err(EngineError::Other("the paused action's document or history changed".into()));
|
||||
}
|
||||
}
|
||||
if self.resumed.swap(true, Ordering::SeqCst) {
|
||||
return Err(EngineError::Other("the paused action was already resumed".into()));
|
||||
}
|
||||
if let Some(group) = &self.group {
|
||||
for before in &group.before {
|
||||
if let Some(st) = s.docs.iter_mut().find(|st| st.doc.id == before.id) {
|
||||
let prefix = st.history.current_visibility().to_vec();
|
||||
st.history.begin_visibility_capture(&prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.action_history = self.group.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebind a completed nonmodal dialog edit, leaving open-dialog time outside its scope.
|
||||
/// Only the scope resumed from this continuation may be taken; a parent remains untouched.
|
||||
pub fn suspend_after_edit(&self, s: &mut Session) -> Result<Self> {
|
||||
if s.actions.playing != 0 || s.active().map(|st| st.doc.id) != self.active {
|
||||
return Err(EngineError::Other("the dialog's action scope is no longer current".into()));
|
||||
}
|
||||
let active = self.documents.iter().find(|doc| Some(doc.id) == self.active);
|
||||
let at_dialog_boundary = |before: &BeforeDocument| active.is_some_and(|doc| before.id == doc.id && Arc::ptr_eq(&before.doc, &doc.doc));
|
||||
let owned = match (&self.group, &s.action_history) {
|
||||
(None, None) => true,
|
||||
(Some(expected), Some(current)) => {
|
||||
expected.name == current.name
|
||||
&& expected.external == current.external
|
||||
&& current.before.len() >= expected.before.len()
|
||||
&& current.before.len() <= expected.before.len().saturating_add(1)
|
||||
&& expected
|
||||
.before
|
||||
.iter()
|
||||
.zip(¤t.before)
|
||||
.all(|(before, now)| before.id == now.id && (Arc::ptr_eq(&before.doc, &now.doc) || at_dialog_boundary(now)))
|
||||
&& current.before.iter().skip(expected.before.len()).all(at_dialog_boundary)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !owned {
|
||||
return Err(EngineError::Other("the dialog no longer owns the active action scope".into()));
|
||||
}
|
||||
Ok(Self::capture(s))
|
||||
}
|
||||
|
||||
/// Publish the prefix and capture the same document/history/source binding for both callers.
|
||||
fn capture(s: &mut Session) -> Self {
|
||||
s.paused_action_history.retain(|lease| lease.strong_count() != 0);
|
||||
let policy_lease = Arc::new(());
|
||||
s.paused_action_history.push(Arc::downgrade(&policy_lease));
|
||||
let group = s.action_history.take();
|
||||
if let Some(group) = &group {
|
||||
consolidate(s, group);
|
||||
}
|
||||
let active = s.active().map(|st| st.doc.id);
|
||||
let documents = s
|
||||
.docs
|
||||
.iter()
|
||||
.filter(|st| Some(st.doc.id) == active || group.as_ref().is_some_and(|g| g.before.iter().any(|b| b.id == st.doc.id)))
|
||||
.map(|st| PausedDocument { id: st.doc.id, doc: st.doc.clone(), history: HistoryBinding::of(&st.history), source_snapshot: st.source_snapshot })
|
||||
.collect();
|
||||
Self { _policy_lease: policy_lease, resumed: Arc::new(AtomicBool::new(false)), group, active, documents }
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the completed prefix, then release the active scope while a dialog is open.
|
||||
pub fn suspend_history(s: &mut Session) -> Option<ActionHistoryContinuation> {
|
||||
if s.actions.playing != 1 {
|
||||
return None;
|
||||
}
|
||||
Some(ActionHistoryContinuation::capture(s))
|
||||
}
|
||||
|
||||
/// End a dialog scope if its command or continuation fails before playback starts.
|
||||
pub fn finish_pending_history(s: &mut Session) {
|
||||
if s.actions.playing == 0 {
|
||||
finish_playback(s);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Batch 16 review: a kept clone can't reopen a scope its continuation already resumed and
|
||||
/// finished, even with the document and history unchanged.
|
||||
#[test]
|
||||
fn a_continuation_resumes_once() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
|
||||
s.execute("actions.setHistory", json!({"singleState":true})).unwrap();
|
||||
begin_document_history(&mut s, "Spell");
|
||||
let continuation = ActionHistoryContinuation::capture(&mut s);
|
||||
let kept = continuation.clone();
|
||||
continuation.resume(&mut s).unwrap();
|
||||
finish_document_history(&mut s);
|
||||
assert!(kept.resume(&mut s).is_err(), "the clone shares the resumed mark");
|
||||
assert!(s.action_history.is_none());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
/// Keep the policy fixed until every clone of a paused continuation releases its lease, in either history mode.
|
||||
fn history_policy_stays_fixed_until_all_paused_continuations_are_released() {
|
||||
for single in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("actions.setHistory", json!({"singleState":single})).unwrap();
|
||||
begin_document_history(&mut s, "Paused");
|
||||
let continuation = ActionHistoryContinuation::capture(&mut s);
|
||||
let copy = continuation.clone();
|
||||
assert!(s.execute("actions.setHistory", json!({"singleState":!single})).is_err());
|
||||
assert_eq!(s.prefs().actions.create_single_history_state, single);
|
||||
continuation.resume(&mut s).unwrap();
|
||||
finish_document_history(&mut s);
|
||||
drop(continuation);
|
||||
assert!(s.execute("actions.setHistory", json!({"singleState":!single})).is_err());
|
||||
drop(copy);
|
||||
s.execute("actions.setHistory", json!({"singleState":!single})).unwrap();
|
||||
assert_eq!(s.prefs().actions.create_single_history_state, !single);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Generic preference set/reset routes must preserve the same paused-policy guard as actions.setHistory.
|
||||
fn generic_preference_updates_cannot_change_a_paused_history_policy() {
|
||||
for single in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("actions.setHistory", json!({"singleState":single})).unwrap();
|
||||
begin_document_history(&mut s, "Paused");
|
||||
let continuation = ActionHistoryContinuation::capture(&mut s);
|
||||
for params in [
|
||||
json!({"path":"actions.createSingleHistoryState","value":!single}),
|
||||
json!({"path":"actions","value":{"createSingleHistoryState":!single}}),
|
||||
json!({"path":"","value":{"actions":{"createSingleHistoryState":!single}}}),
|
||||
json!({"path":"colorSettings.intent","value":"perceptual","values":{"actions.createSingleHistoryState":!single}}),
|
||||
] {
|
||||
let before = s.prefs().clone();
|
||||
let color = s.color.settings.clone();
|
||||
assert!(s.execute("prefs.set", params).is_err());
|
||||
assert_eq!(s.prefs(), &before);
|
||||
assert_eq!(s.color.settings, color);
|
||||
}
|
||||
for params in [json!({"path":"actions.createSingleHistoryState"}), json!({"path":"actions"}), json!({})] {
|
||||
assert_eq!(s.execute("prefs.reset", params).is_err(), !single);
|
||||
assert_eq!(s.prefs().actions.create_single_history_state, single);
|
||||
}
|
||||
s.execute("prefs.set", json!({"path":"actions.createSingleHistoryState","value":single})).unwrap();
|
||||
s.execute("prefs.set", json!({"path":"performance.historyStates","value":17})).unwrap();
|
||||
s.execute("prefs.reset", json!({"path":"cursors"})).unwrap();
|
||||
drop(continuation);
|
||||
s.execute("prefs.set", json!({"path":"actions.createSingleHistoryState","value":!single})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Rebinding a dialog retains its first edit while respecting an accepted Clear History boundary.
|
||||
fn idle_dialog_rebind_keeps_first_edit_and_clear_boundaries() {
|
||||
for single in [false, true] {
|
||||
for clear in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
|
||||
s.execute("actions.setHistory", json!({"singleState":single})).unwrap();
|
||||
begin_document_history(&mut s, "Spell");
|
||||
if clear {
|
||||
s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap();
|
||||
s.execute("history.clear", json!({})).unwrap();
|
||||
}
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let mut continuation = ActionHistoryContinuation::capture(&mut s);
|
||||
for name in ["First correction", "Second correction"] {
|
||||
continuation.resume(&mut s).unwrap();
|
||||
s.execute("layer.renameLayer", json!({"name":name})).unwrap();
|
||||
continuation = continuation.suspend_after_edit(&mut s).unwrap();
|
||||
assert!(s.action_history.is_none(), "idle time must not own a history scope");
|
||||
assert_eq!(s.actions.playing, 0);
|
||||
}
|
||||
continuation.resume(&mut s).unwrap();
|
||||
finish_document_history(&mut s);
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert!(s.undo());
|
||||
if single {
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
} else {
|
||||
assert_ne!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
}
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A dialog continuation cannot adopt another caller's active grouping scope.
|
||||
fn idle_dialog_rebind_never_takes_an_unrelated_parent_scope() {
|
||||
for single in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
|
||||
s.execute("actions.setHistory", json!({"singleState":single})).unwrap();
|
||||
begin_document_history(&mut s, "Spell");
|
||||
let continuation = ActionHistoryContinuation::capture(&mut s);
|
||||
// An independently owned parent scope must never be taken, even for a paused
|
||||
// per-step action whose policy is now protected from changes.
|
||||
s.action_history = Some(ActionHistory { name: "Parent".into(), external: true, before: Vec::new() });
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let rows = s.active().unwrap().history.entries();
|
||||
assert!(continuation.suspend_after_edit(&mut s).is_err());
|
||||
assert!(suspend_history(&mut s).is_none(), "ordinary suspension still requires playing == 1");
|
||||
assert_eq!(s.action_history.as_ref().unwrap().name, "Parent");
|
||||
assert!(Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.entries(), rows);
|
||||
finish_document_history(&mut s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,9 +544,12 @@ fn record_keeps_only_replayable_steps() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Playback on a different document must match native edits at every depth, independent of history grouping.
|
||||
fn play_on_another_document_matches_pixels_at_each_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
// This control checks the optional, per-step history behavior.
|
||||
s.execute("actions.setHistory", json!({"singleState": false})).unwrap();
|
||||
record_red(&mut s, depth);
|
||||
s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap();
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
@@ -1238,9 +1241,12 @@ fn button_color_is_one_of_the_photoshop_swatches() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// An arrangement that cannot move the layer must not create a grouped history entry.
|
||||
fn arrange_that_cannot_move_is_a_noop_in_playback() {
|
||||
// #8453: imported actions make an adjustment layer, then Bring it to Front, where it already is.
|
||||
let mut s = Session::new();
|
||||
// Keep individual rows visible so grouping cannot hide an extra no-op record.
|
||||
s.execute("actions.setHistory", json!({"singleState": false})).unwrap();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let steps = vec![
|
||||
("layer.newAdjustmentLayer.curves".to_string(), json!({})),
|
||||
|
||||
@@ -175,6 +175,7 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> {
|
||||
/// as it was and the journal forgets the commands `f` ran, so an action being recorded does not
|
||||
/// replay an edit that was taken back.
|
||||
pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
|
||||
crate::actions_cmds::before_action_edit(s);
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target());
|
||||
// An inner command may select a layer that disappears when the compound rolls back.
|
||||
|
||||
@@ -35,14 +35,17 @@ mod files;
|
||||
|
||||
mod steps_artboard;
|
||||
mod steps_channels;
|
||||
mod steps_content_aware;
|
||||
mod steps_direct_adjustments;
|
||||
mod steps_distort;
|
||||
mod steps_doc;
|
||||
mod steps_gallery;
|
||||
mod steps_gradient;
|
||||
mod steps_image;
|
||||
mod steps_layer;
|
||||
mod steps_layer_style;
|
||||
mod steps_make;
|
||||
mod steps_type_path;
|
||||
|
||||
mod steps_stylize;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::descriptors::{
|
||||
use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step};
|
||||
use super::steps_artboard::{artboard_edit_from, artboard_edit_to, artboard_from, artboard_to};
|
||||
use super::steps_channels::{match_color_from, match_color_to, merge_spot_from, merge_spot_to, split_from, split_to, trap_from, trap_to};
|
||||
use super::steps_content_aware::{caf_from, caf_to};
|
||||
use super::steps_direct_adjustments::{gradient_from, gradient_to, replace_from, replace_to};
|
||||
use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, shear_from, shear_to, wave_from, wave_to};
|
||||
use super::steps_doc::{
|
||||
@@ -799,6 +800,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
label: "Make",
|
||||
ids: &[
|
||||
"type.convertToShape",
|
||||
"type.createWorkPath",
|
||||
"layer.new.layer",
|
||||
"layer.layerStyle.createLayer",
|
||||
"layer.new.group",
|
||||
@@ -1051,6 +1053,14 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
ev!("", "autoCutout", "Select Subject", "select.subject", &[F("sampleAllLayers", "sampleAllLayers", Kind::Bool)]),
|
||||
Event { code: "ClrR", name: "colorRange", label: "Color Range", ids: &["select.colorRange"], from: color_range_from, to: color_range_to },
|
||||
// Edit.
|
||||
Event {
|
||||
code: "Grdn",
|
||||
name: "gradientClassEvent",
|
||||
label: "Gradient",
|
||||
ids: &["paint.gradient"],
|
||||
from: super::steps_gradient::from,
|
||||
to: super::steps_gradient::to,
|
||||
},
|
||||
Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to },
|
||||
Event {
|
||||
code: "Strk",
|
||||
@@ -1094,6 +1104,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
from: |d| profile_from(d, "edit.convertToProfile"),
|
||||
to: profile_to,
|
||||
},
|
||||
Event { code: "", name: "cafWorkspace", label: "Content-Aware Fill", ids: &["edit.contentAwareFill"], from: caf_from, to: caf_to },
|
||||
// File and actions.
|
||||
Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to },
|
||||
Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to },
|
||||
|
||||
@@ -261,30 +261,51 @@ fn render_actions_match_direct_filters_and_undo() {
|
||||
// Just right of the canvas: the dialog's 0..1 range does not clamp a recorded centre.
|
||||
(*b"LnsF", flare(100, [1.4, 0.25], "Zm"), "filter.render.lensFlare", json!({"brightness": 100, "centerX": 1.4, "centerY": 0.25, "lens": "zoom"})),
|
||||
];
|
||||
for mode in ["rgb", "gray"] {
|
||||
for depth in [8, 16, 32] {
|
||||
for (event, descriptor, id, params) in &cases {
|
||||
let setup = || {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6, "mode": mode, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#304050"})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#fe1020", "background": "#1050e0"})).unwrap();
|
||||
s
|
||||
};
|
||||
let mut action = setup();
|
||||
let mut direct = setup();
|
||||
let before = action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap();
|
||||
action.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(AtnEvent::Code(*event), descriptor.clone()))})).unwrap();
|
||||
let played = action.execute("actions.play", json!({"action": "Render"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
direct.execute(id, params.clone()).unwrap();
|
||||
let surface = |s: &Session| {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds())
|
||||
};
|
||||
assert_eq!(surface(&action), surface(&direct), "{mode}/{depth}/{id}");
|
||||
action.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(), before);
|
||||
for single_state in [true, false] {
|
||||
for mode in ["rgb", "gray"] {
|
||||
for depth in [8, 16, 32] {
|
||||
for (event, descriptor, id, params) in &cases {
|
||||
let setup = || {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6, "mode": mode, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#304050"})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#fe1020", "background": "#1050e0"})).unwrap();
|
||||
s
|
||||
};
|
||||
let mut action = setup();
|
||||
let mut direct = setup();
|
||||
action.execute("actions.setHistory", json!({"singleState": single_state})).unwrap();
|
||||
let before_doc = action.active().unwrap().doc.clone();
|
||||
let history = action.active().unwrap().history.past_len();
|
||||
let before = action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap();
|
||||
action
|
||||
.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(AtnEvent::Code(*event), descriptor.clone()))}))
|
||||
.unwrap();
|
||||
let played = action.execute("actions.play", json!({"action": "Render"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
direct.execute(id, params.clone()).unwrap();
|
||||
let surface = |s: &Session| {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds())
|
||||
};
|
||||
assert_eq!(surface(&action), surface(&direct), "{mode}/{depth}/{id}");
|
||||
let after_doc = action.active().unwrap().doc.clone();
|
||||
let changed = after_doc.as_ref() != before_doc.as_ref();
|
||||
let added = usize::from(!single_state || changed);
|
||||
assert_eq!(action.active().unwrap().history.past_len(), history + added, "{mode}/{depth}/{id}, singleState={single_state}");
|
||||
// An unchanged grouped action preserves the setup row instead of adding one.
|
||||
if added > 0 {
|
||||
action.execute("edit.undo", json!({})).unwrap();
|
||||
} else {
|
||||
assert!(std::sync::Arc::ptr_eq(&action.active().unwrap().doc, &before_doc));
|
||||
}
|
||||
assert_eq!(action.active().unwrap().doc.as_ref(), before_doc.as_ref(), "{mode}/{depth}/{id} undo");
|
||||
assert_eq!(action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(), before);
|
||||
if added > 0 {
|
||||
action.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(action.active().unwrap().doc.as_ref(), after_doc.as_ref(), "{mode}/{depth}/{id} redo");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Recorded Content-Aware Fill settings; selection and job/history behavior belong to the command.
|
||||
use super::descriptors::{desc, enm, jstr};
|
||||
use super::fields::{Mapped, step};
|
||||
use super::tables::lookup_back;
|
||||
use super::{D, Descriptor, Map, Value};
|
||||
|
||||
const CMD: &str = "edit.contentAwareFill";
|
||||
const SOURCE: &str = "_atnCafDescriptor";
|
||||
// The measured row bounds these values; its ellipses are not additional enum bindings.
|
||||
const SAMPLING: &[(&str, &str)] = &[("cafSamplingRegionRectangular", "rectangular")];
|
||||
const COLOR: &[(&str, &str)] = &[("cafColorAdaptationDefault", "default")];
|
||||
const ROTATION: &[(&str, &str)] = &[("cafRotationAmountNone", "none")];
|
||||
// Current Layer is also explicit in the primary Action Manager example cited in docs/atn.md.
|
||||
const OUTPUT: &[(&str, &str)] = &[("cafOutputToNewLayer", "new"), ("cafOutputToCurrentLayer", "current")];
|
||||
const KEYS: &[&str] = &["cafSamplingRegion", "cafSampleAllLayers", "cafColorAdaptationLevel", "cafRotationAmount", "cafScale", "cafMirror", "cafOutput"];
|
||||
const PARAMS: &[&str] = &["sampling", "sampleAllLayers", "colorAdaptation", "rotationAdaptation", "scale", "mirror", "output"];
|
||||
|
||||
/// All seven settings are recorded. Missing keys have no measured Photoshop default here.
|
||||
fn recorded(d: &Descriptor) -> Option<Value> {
|
||||
if d.items.len() != KEYS.len() || KEYS.iter().any(|name| d.items.iter().filter(|(key, _)| key.is(name)).count() != 1) {
|
||||
return None;
|
||||
}
|
||||
let mut p = Map::new();
|
||||
for (key, param, table) in [(KEYS[0], PARAMS[0], SAMPLING), (KEYS[2], PARAMS[2], COLOR), (KEYS[3], PARAMS[3], ROTATION), (KEYS[6], PARAMS[6], OUTPUT)] {
|
||||
let D::Enumerated { type_id, value } = d.get(key)? else { return None };
|
||||
if !type_id.is(key) {
|
||||
return None;
|
||||
}
|
||||
p.insert(param.into(), table.iter().find(|(wire, _)| value.is(wire))?.1.into());
|
||||
}
|
||||
for (key, param) in [(KEYS[1], PARAMS[1]), (KEYS[4], PARAMS[4]), (KEYS[5], PARAMS[5])] {
|
||||
let D::Boolean(value) = d.get(key)? else { return None };
|
||||
p.insert(param.into(), (*value).into());
|
||||
}
|
||||
Some(Value::Object(p))
|
||||
}
|
||||
|
||||
/// Write only represented native options, making their defaults explicit in all seven fields.
|
||||
fn descriptor(p: &Value) -> Option<Descriptor> {
|
||||
let object = p.as_object()?;
|
||||
if !super::events::pixel_target(p)
|
||||
|| object.keys().any(|key| !PARAMS.contains(&key.as_str()) && !matches!(key.as_str(), "target" | "seed" | SOURCE))
|
||||
|| p.get("seed").is_some_and(|seed| seed.as_u64() != Some(1))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let enumeration = |key: &str, param: &str, table: &[(&str, &str)], default: &str| {
|
||||
let value = match p.get(param) {
|
||||
None => default,
|
||||
Some(value) => value.as_str()?,
|
||||
};
|
||||
Some(enm(key, lookup_back(table, value)?))
|
||||
};
|
||||
let boolean = |param: &str| match p.get(param) {
|
||||
None => Some(D::Boolean(false)),
|
||||
Some(value) => value.as_bool().map(D::Boolean),
|
||||
};
|
||||
Some(
|
||||
desc("null")
|
||||
.with(KEYS[0], enumeration(KEYS[0], PARAMS[0], SAMPLING, "auto")?)
|
||||
.with(KEYS[1], boolean(PARAMS[1])?)
|
||||
.with(KEYS[2], enumeration(KEYS[2], PARAMS[2], COLOR, "default")?)
|
||||
.with(KEYS[3], enumeration(KEYS[3], PARAMS[3], ROTATION, "none")?)
|
||||
.with(KEYS[4], boolean(PARAMS[4])?)
|
||||
.with(KEYS[5], boolean(PARAMS[5])?)
|
||||
.with(KEYS[6], enumeration(KEYS[6], PARAMS[6], OUTPUT, "current")?),
|
||||
)
|
||||
}
|
||||
|
||||
/// Import the verified seven-field form, retaining noncanonical wire data for an unchanged save.
|
||||
pub(super) fn caf_from(d: &Descriptor) -> Mapped {
|
||||
let Some(mut p) = recorded(d) else { return Mapped::Unsupported { harmless: false } };
|
||||
if descriptor(&p).as_ref() != Some(d) {
|
||||
// Key order, descriptor identity and ID encoding are wire data, not native options.
|
||||
p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into();
|
||||
}
|
||||
step(CMD, p)
|
||||
}
|
||||
|
||||
/// Export supported settings; preserve the recorded wire form only while its settings agree.
|
||||
pub(super) fn caf_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
let canonical = descriptor(p)?;
|
||||
if p.get(SOURCE).is_none() {
|
||||
return Some(canonical);
|
||||
}
|
||||
let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?;
|
||||
// Validate preserved data even after an edit; it cannot hide unsupported source options.
|
||||
let original = descriptor(&recorded(&source)?)?;
|
||||
// A supported edit has a verified canonical form; stale wire data cannot override it.
|
||||
Some(if original == canonical { source } else { canonical })
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Classic Gradient recordings (#10710), using the existing pixel-paint command.
|
||||
//!
|
||||
//! Presets have centred midpoints and RGB byte colours. Other gradient forms stay
|
||||
//! kept steps rather than losing settings on playback. Source descriptors are
|
||||
//! retained only after decoding to the same executable parameters.
|
||||
|
||||
use super::convert::normalise;
|
||||
use super::descriptors::{desc, enm, pct, px, trimmed};
|
||||
use super::events::pixel_target;
|
||||
use super::fields::{Mapped, step};
|
||||
use super::tables::{blend_label, blend_ps};
|
||||
use super::{D, Descriptor, Value, json};
|
||||
|
||||
const ORIGIN: &str = "__atnClassicGradient";
|
||||
const MAX_STOPS: usize = 64;
|
||||
const MAX_DESCRIPTOR: usize = 64 * 1024;
|
||||
const STYLES: &[(&str, &str)] = &[("Lnr ", "linear"), ("Rdl ", "radial"), ("Angl", "angle"), ("Rflc", "reflected"), ("Dmnd", "diamond")];
|
||||
const METHODS: &[(&str, &str)] = &[("Gcls", "classic"), ("Perc", "perceptual"), ("Lnr ", "linear"), ("Smoo", "smooth")];
|
||||
|
||||
/// Extra or repeated fields must not disappear from the operation being played.
|
||||
fn shape(d: &Descriptor, class: &str, keys: &[&str]) -> Option<()> {
|
||||
if !d.class_id.is(class) || d.name.0.len() > 1024 || d.items.len() > keys.len() {
|
||||
return None;
|
||||
}
|
||||
for (i, (key, _)) in d.items.iter().enumerate() {
|
||||
if !keys.iter().any(|k| key.is(k)) || d.items.iter().take(i).any(|(other, _)| other.as_bytes() == key.as_bytes()) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn object<'a>(d: &'a Descriptor, key: &str) -> Option<&'a Descriptor> {
|
||||
match d.get(key)? {
|
||||
D::Descriptor(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(v: &D, unit: Option<&[u8; 4]>) -> Option<f64> {
|
||||
let n = match (v, unit) {
|
||||
(D::Integer(n), None) => f64::from(*n),
|
||||
(D::Double(n), None) => *n,
|
||||
(D::UnitFloat { unit: actual, value }, Some(expected)) if actual == expected => *value,
|
||||
_ => return None,
|
||||
};
|
||||
(n.is_finite() && (n as f32).is_finite()).then_some(n)
|
||||
}
|
||||
|
||||
fn enumeration<'a>(v: &'a D, ty: &str) -> Option<&'a super::Id> {
|
||||
match v {
|
||||
D::Enumerated { type_id, value } if type_id.is(ty) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn flag(d: &Descriptor, key: &str, default: bool) -> Option<bool> {
|
||||
match d.get(key) {
|
||||
None => Some(default),
|
||||
Some(D::Boolean(v)) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn point(d: &Descriptor, key: &str) -> Option<Value> {
|
||||
let p = object(d, key)?;
|
||||
shape(p, "Pnt ", &["Hrzn", "Vrtc"])?;
|
||||
Some(json!([number(p.get("Hrzn")?, Some(b"#Pxl"))?, number(p.get("Vrtc")?, Some(b"#Pxl"))?]))
|
||||
}
|
||||
|
||||
fn list<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [D]> {
|
||||
match d.get(key)? {
|
||||
D::List(v) if (2..=MAX_STOPS).contains(&v.len()) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn location(d: &Descriptor, previous: &mut f64) -> Option<f64> {
|
||||
let D::Integer(position) = d.get("Lctn")? else { return None };
|
||||
let n = f64::from(*position) / 4096.0;
|
||||
if !(0.0..=1.0).contains(&n) || n < *previous || !matches!(d.get("Mdpn"), Some(D::Integer(50))) {
|
||||
return None;
|
||||
}
|
||||
*previous = n;
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// Decode every accepted setting; no source metadata is ever executed.
|
||||
fn params(d: &Descriptor) -> Option<Value> {
|
||||
// Recordings use the event class; PhotoCraft's canonical exports use null.
|
||||
let class = if d.class_id.is("Grdn") { "Grdn" } else { "null" };
|
||||
shape(d, class, &["From", "T ", "Opct", "Md ", "Type", "Rvrs", "Dthr", "UsMs", "gradientsInterpolationMethod", "Grad"])?;
|
||||
let style = enumeration(d.get("Type")?, "GrdT")?;
|
||||
let style = STYLES.iter().find(|(ps, _)| style.is(ps))?.1;
|
||||
// No omitted-method default was established by the recorded-tools reference.
|
||||
let method = enumeration(d.get("gradientsInterpolationMethod")?, "gradientInterpolationMethodType")?;
|
||||
let method = METHODS.iter().find(|(ps, _)| method.is(ps))?.1;
|
||||
let opacity = match d.get("Opct") {
|
||||
None => 100.0,
|
||||
Some(v) => number(v, Some(b"#Prc"))?,
|
||||
};
|
||||
if !(1.0..=100.0).contains(&opacity) {
|
||||
return None;
|
||||
}
|
||||
let mode = match d.get("Md ") {
|
||||
None => "Normal",
|
||||
Some(v) => blend_label(&trimmed(enumeration(v, "BlnM")?))?,
|
||||
};
|
||||
if mode == "Pass Through" {
|
||||
return None;
|
||||
}
|
||||
let g = object(d, "Grad")?;
|
||||
shape(g, "Grdn", &["Nm ", "GrdF", "Intr", "Clrs", "Trns"])?;
|
||||
let D::Text(name) = g.get("Nm ")? else { return None };
|
||||
if name.0.len() > 1024 || !enumeration(g.get("GrdF")?, "GrdF")?.is("CstS") {
|
||||
return None;
|
||||
}
|
||||
let smoothness = number(g.get("Intr")?, None)? * 100.0 / 4096.0;
|
||||
if !(0.0..=100.0).contains(&smoothness) {
|
||||
return None;
|
||||
}
|
||||
let mut stops = Vec::new();
|
||||
let mut previous = -1.0;
|
||||
for s in list(g, "Clrs")? {
|
||||
let D::Descriptor(s) = s else { return None };
|
||||
shape(s, "Clrt", &["Clr ", "Type", "Lctn", "Mdpn"])?;
|
||||
if !enumeration(s.get("Type")?, "Clry")?.is("UsrS") {
|
||||
return None;
|
||||
}
|
||||
let c = object(s, "Clr ")?;
|
||||
shape(c, "RGBC", &["Rd ", "Grn ", "Bl "])?;
|
||||
let rgb = [number(c.get("Rd ")?, None)?, number(c.get("Grn ")?, None)?, number(c.get("Bl ")?, None)?];
|
||||
if !rgb.iter().all(|v| (0.0..=255.0).contains(v) && v.fract() == 0.0) {
|
||||
return None;
|
||||
}
|
||||
stops.push(json!([location(s, &mut previous)?, format!("#{:02x}{:02x}{:02x}", rgb[0] as u8, rgb[1] as u8, rgb[2] as u8)]));
|
||||
}
|
||||
let use_transparency = flag(d, "UsMs", false)?;
|
||||
let mut transparency = Vec::new();
|
||||
previous = -1.0;
|
||||
for s in list(g, "Trns")? {
|
||||
let D::Descriptor(s) = s else { return None };
|
||||
shape(s, "TrnS", &["Opct", "Lctn", "Mdpn"])?;
|
||||
let opacity = number(s.get("Opct")?, Some(b"#Prc"))?;
|
||||
if !(0.0..=100.0).contains(&opacity) {
|
||||
return None;
|
||||
}
|
||||
let t = location(s, &mut previous)?;
|
||||
if use_transparency {
|
||||
transparency.push(json!([t, opacity]));
|
||||
}
|
||||
}
|
||||
Some(json!({"from":point(d,"From")?,"to":point(d,"T ")?,"style":style,"opacity":opacity,"mode":mode,
|
||||
"reverse":flag(d,"Rvrs",false)?,"dither":flag(d,"Dthr",false)?,"stops":stops,
|
||||
"transparency":transparency,"method":method,"smoothness":smoothness}))
|
||||
}
|
||||
|
||||
pub(super) fn from(d: &Descriptor) -> Mapped {
|
||||
let mapped = || {
|
||||
let mut p = params(d)?;
|
||||
let bytes = d.to_bytes();
|
||||
if bytes.len() > MAX_DESCRIPTOR {
|
||||
return None;
|
||||
}
|
||||
p[ORIGIN] = json!(crate::paint::tile::b64_encode(&bytes));
|
||||
Some(p)
|
||||
};
|
||||
mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("paint.gradient", p))
|
||||
}
|
||||
|
||||
fn finite(v: &Value, lo: f64, hi: f64) -> Option<f64> {
|
||||
v.as_f64().filter(|v| v.is_finite() && (lo..=hi).contains(v))
|
||||
}
|
||||
|
||||
fn native_point(p: &Value, key: &str) -> Option<D> {
|
||||
let a = p.get(key)?.as_array().filter(|a| a.len() == 2)?;
|
||||
let coordinate = |i: usize| a.get(i)?.as_f64().filter(|v| v.is_finite() && (*v as f32).is_finite());
|
||||
Some(D::Descriptor(desc("Pnt ").with("Hrzn", px(coordinate(0)?)).with("Vrtc", px(coordinate(1)?))))
|
||||
}
|
||||
|
||||
fn native_location(a: &[Value], previous: &mut f64) -> Option<i32> {
|
||||
let t = finite(a.first()?, 0.0, 1.0)? * 4096.0;
|
||||
if t.fract() != 0.0 || t < *previous {
|
||||
return None;
|
||||
}
|
||||
*previous = t;
|
||||
Some(t as i32)
|
||||
}
|
||||
|
||||
/// Canonical edited/native form. Never substitute current presets or drop a target.
|
||||
fn canonical(p: &Value) -> Option<Descriptor> {
|
||||
let style = STYLES.iter().find(|(_, native)| Some(*native) == p.get("style").map_or(Some("linear"), Value::as_str))?.0;
|
||||
let method = METHODS.iter().find(|(_, native)| Some(*native) == p.get("method").and_then(Value::as_str))?.0;
|
||||
let smoothness = finite(p.get("smoothness").unwrap_or(&json!(100)), 0.0, 100.0)?;
|
||||
let mut colors = Vec::new();
|
||||
let mut previous = -1.0;
|
||||
for a in p.get("stops")?.as_array().filter(|a| (2..=MAX_STOPS).contains(&a.len()))? {
|
||||
let a = a.as_array().filter(|a| a.len() == 2)?;
|
||||
let text = a.get(1)?.as_str()?.strip_prefix('#')?;
|
||||
if text.len() != 6 || !text.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let rgb = u32::from_str_radix(text, 16).ok()?;
|
||||
let c = desc("RGBC")
|
||||
.with("Rd ", D::Double(f64::from((rgb >> 16) & 255)))
|
||||
.with("Grn ", D::Double(f64::from((rgb >> 8) & 255)))
|
||||
.with("Bl ", D::Double(f64::from(rgb & 255)));
|
||||
colors.push(D::Descriptor(
|
||||
desc("Clrt")
|
||||
.with("Clr ", D::Descriptor(c))
|
||||
.with("Type", enm("Clry", "UsrS"))
|
||||
.with("Lctn", D::Integer(native_location(a, &mut previous)?))
|
||||
.with("Mdpn", D::Integer(50)),
|
||||
));
|
||||
}
|
||||
let mut transparency = Vec::new();
|
||||
previous = -1.0;
|
||||
let opacity_stops = p.get("transparency")?.as_array().filter(|a| a.len() <= MAX_STOPS)?;
|
||||
if opacity_stops.len() == 1 {
|
||||
return None;
|
||||
}
|
||||
for a in opacity_stops {
|
||||
let a = a.as_array().filter(|a| a.len() == 2)?;
|
||||
transparency.push(D::Descriptor(
|
||||
desc("TrnS")
|
||||
.with("Opct", pct(finite(a.get(1)?, 0.0, 100.0)?))
|
||||
.with("Lctn", D::Integer(native_location(a, &mut previous)?))
|
||||
.with("Mdpn", D::Integer(50)),
|
||||
));
|
||||
}
|
||||
let use_transparency = !transparency.is_empty();
|
||||
if !use_transparency {
|
||||
transparency = [0, 4096]
|
||||
.into_iter()
|
||||
.map(|t| D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(t)).with("Mdpn", D::Integer(50))))
|
||||
.collect();
|
||||
}
|
||||
let gradient = desc("Grdn")
|
||||
.with("Nm ", super::descriptors::text("Custom"))
|
||||
.with("GrdF", enm("GrdF", "CstS"))
|
||||
.with("Intr", D::Double(smoothness * 4096.0 / 100.0))
|
||||
.with("Clrs", D::List(colors))
|
||||
.with("Trns", D::List(transparency));
|
||||
let mut d = desc("null").with("From", native_point(p, "from")?).with("T ", native_point(p, "to")?);
|
||||
let opacity = finite(p.get("opacity").unwrap_or(&json!(100)), 1.0, 100.0)?;
|
||||
if opacity != 100.0 {
|
||||
d = d.with("Opct", pct(opacity));
|
||||
}
|
||||
let mode = blend_ps(p.get("mode").map_or(Some("Normal"), Value::as_str)?)?;
|
||||
if mode == "passThrough" || mode == "PsTh" {
|
||||
return None;
|
||||
}
|
||||
if mode != "Nrml" {
|
||||
d = d.with("Md ", enm("BlnM", mode));
|
||||
}
|
||||
d = d.with("Type", enm("GrdT", style));
|
||||
for (param, key, default) in [("reverse", "Rvrs", false), ("dither", "Dthr", true)] {
|
||||
let value = p.get(param).map_or(Some(default), Value::as_bool)?;
|
||||
if value {
|
||||
d = d.with(key, D::Boolean(true));
|
||||
}
|
||||
}
|
||||
if use_transparency {
|
||||
d = d.with("UsMs", D::Boolean(true));
|
||||
}
|
||||
Some(d.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", method)).with("Grad", D::Descriptor(gradient)))
|
||||
}
|
||||
|
||||
pub(super) fn to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
let values = p.as_object()?;
|
||||
if !pixel_target(p)
|
||||
|| !values.keys().all(|k| {
|
||||
["from", "to", "style", "opacity", "mode", "reverse", "dither", "stops", "transparency", "method", "smoothness", "target", ORIGIN]
|
||||
.contains(&k.as_str())
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(origin) = p.get(ORIGIN) {
|
||||
let text = origin.as_str().filter(|v| v.len() <= MAX_DESCRIPTOR * 2)?;
|
||||
let bytes = crate::paint::tile::b64_decode(text)?;
|
||||
if bytes.len() > MAX_DESCRIPTOR {
|
||||
return None;
|
||||
}
|
||||
let d = Descriptor::from_bytes(&bytes).ok()?;
|
||||
let mut expected = params(&d)?;
|
||||
let mut actual = p.clone();
|
||||
actual.as_object_mut()?.remove(ORIGIN);
|
||||
actual.as_object_mut()?.remove("target");
|
||||
normalise(&mut expected);
|
||||
normalise(&mut actual);
|
||||
if expected == actual {
|
||||
return Some(d);
|
||||
}
|
||||
}
|
||||
canonical(p)
|
||||
}
|
||||
@@ -179,6 +179,9 @@ fn type_shape_descriptor() -> Descriptor {
|
||||
/// Import Make events only when their geometry and options can be represented faithfully,
|
||||
/// including the exact target-text-to-content-layer references.
|
||||
pub(super) fn make_from(d: &Descriptor) -> Mapped {
|
||||
if get_ref(d, "null").and_then(ref_class).as_deref() == Some("Path") {
|
||||
return super::steps_type_path::from(d);
|
||||
}
|
||||
if let Some(mapped) = super::steps_layer_style::create_from_descriptor(d) {
|
||||
return mapped;
|
||||
}
|
||||
@@ -314,6 +317,9 @@ fn recorded_percent(opacity: f64) -> f64 {
|
||||
/// Write a creation step, refusing native options the target descriptor cannot carry; Type to
|
||||
/// Shape exports only without native options.
|
||||
pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
if id == super::steps_type_path::COMMAND {
|
||||
return super::steps_type_path::to(p);
|
||||
}
|
||||
if id == super::steps_layer_style::CREATE {
|
||||
return super::steps_layer_style::style_to(id, p);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! The measured Make Path from the target text layer, without guessing named sources.
|
||||
use super::descriptors::{class_ref, desc, get_ref, jstr, target_ref};
|
||||
use super::fields::{Mapped, step};
|
||||
use super::{Descriptor, ReferenceItem, Value, json};
|
||||
|
||||
pub(super) const COMMAND: &str = "type.createWorkPath";
|
||||
const SOURCE: &str = "_atnTypePathDescriptor";
|
||||
|
||||
/// Canonical Make Path descriptor for the active text layer; it carries no path geometry.
|
||||
fn descriptor() -> Descriptor {
|
||||
desc("null").with("null", class_ref("Path")).with("From", target_ref("TxLr"))
|
||||
}
|
||||
|
||||
/// Unknown keys, duplicate keys and different reference chains stay complete kept steps.
|
||||
fn supported(d: &Descriptor) -> bool {
|
||||
if d.items.len() != 2 || ["null", "From"].iter().any(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() != 1) {
|
||||
return false;
|
||||
}
|
||||
matches!(get_ref(d, "null"), Some([ReferenceItem::Class(path)]) if path.class_id.is("Path"))
|
||||
&& matches!(get_ref(d, "From"), Some([ReferenceItem::Enumerated { class, type_id, value }])
|
||||
if class.class_id.is("TxLr") && type_id.is("Ordn") && value.is("Trgt"))
|
||||
}
|
||||
|
||||
/// Map only the measured active-text reference, retaining noncanonical wire identity for export.
|
||||
pub(super) fn from(d: &Descriptor) -> Mapped {
|
||||
if !supported(d) {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
let mut params = json!({});
|
||||
// Descriptor/class names, key order and ID encodings are wire identity, not native options.
|
||||
if *d != descriptor() {
|
||||
params[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into();
|
||||
}
|
||||
step(COMMAND, params)
|
||||
}
|
||||
|
||||
/// Export implicit native playback or a validated original descriptor; explicit targets cannot
|
||||
/// be represented by this measured form and must use the caller's unsupported-step handling.
|
||||
pub(super) fn to(p: &Value) -> Option<Descriptor> {
|
||||
if p.as_object()?.keys().any(|key| key != SOURCE) {
|
||||
return None;
|
||||
}
|
||||
if p.get(SOURCE).is_none() {
|
||||
return Some(descriptor());
|
||||
}
|
||||
let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?;
|
||||
supported(&source).then_some(source)
|
||||
}
|
||||
@@ -360,7 +360,7 @@ fn exports_materialize_native_defaults_and_reject_unrepresentable_settings() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Imported actions produce the same pixels and Undo history as the specified native commands.
|
||||
/// Imported actions match native pixels with either grouped or per-step Undo and Redo.
|
||||
#[test]
|
||||
fn imported_group_d_playback_matches_direct_commands_across_modes_and_depths() {
|
||||
let items: Vec<_> = fixtures()
|
||||
@@ -368,36 +368,50 @@ fn imported_group_d_playback_matches_direct_commands_across_modes_and_depths() {
|
||||
.map(|f| code_item(f.code, f.changed))
|
||||
.chain(["FndE", "Slrz", "Fct ", "Frgm"].map(|code| code_item(code, desc("null"))))
|
||||
.collect();
|
||||
for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "gray"), (8, "cmyk"), (16, "lab")] {
|
||||
let mut imported = load_items(items.clone());
|
||||
imported.actions.list[0].disabled.insert(10);
|
||||
imported.actions.list[0].dialogs.insert(0);
|
||||
let steps = imported.actions.list[0].steps.clone();
|
||||
let mut direct = Session::new();
|
||||
for s in [&mut imported, &mut direct] {
|
||||
prepare_pattern(s, depth, mode);
|
||||
}
|
||||
let before = pixels(&imported);
|
||||
let history = imported.active().unwrap().history.past_len();
|
||||
let result = imported.execute("actions.play", json!({"action":"A"})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{depth}/{mode}: {result}");
|
||||
assert_eq!(result["ran"], 11);
|
||||
for (index, (id, params)) in steps.into_iter().enumerate() {
|
||||
if index != 10 {
|
||||
direct.execute(&id, params).unwrap();
|
||||
for single_state in [true, false] {
|
||||
for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "gray"), (8, "cmyk"), (16, "lab")] {
|
||||
let mut imported = load_items(items.clone());
|
||||
imported.execute("actions.setHistory", json!({"singleState": single_state})).unwrap();
|
||||
imported.actions.list[0].disabled.insert(10);
|
||||
imported.actions.list[0].dialogs.insert(0);
|
||||
let steps = imported.actions.list[0].steps.clone();
|
||||
let mut direct = Session::new();
|
||||
for s in [&mut imported, &mut direct] {
|
||||
prepare_pattern(s, depth, mode);
|
||||
}
|
||||
let before = pixels(&imported);
|
||||
let before_doc = imported.active().unwrap().doc.clone();
|
||||
let history = imported.active().unwrap().history.past_len();
|
||||
let result = imported.execute("actions.play", json!({"action":"A"})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{depth}/{mode}: {result}");
|
||||
assert_eq!(result["ran"], 11);
|
||||
for (index, (id, params)) in steps.into_iter().enumerate() {
|
||||
if index != 10 {
|
||||
direct.execute(&id, params).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(pixels(&imported), pixels(&direct), "{depth}/{mode}");
|
||||
let after_doc = imported.active().unwrap().doc.clone();
|
||||
if single_state {
|
||||
assert_eq!(imported.active().unwrap().history.past_len(), history + 1);
|
||||
} else {
|
||||
assert_eq!(imported.active().unwrap().history.past_len(), direct.active().unwrap().history.past_len());
|
||||
}
|
||||
let added = imported.active().unwrap().history.past_len() - history;
|
||||
assert!(added > 0);
|
||||
for _ in 0..added {
|
||||
imported.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(pixels(&imported), before, "{depth}/{mode} undo");
|
||||
assert_eq!(imported.active().unwrap().doc.as_ref(), before_doc.as_ref(), "{depth}/{mode} undo");
|
||||
for _ in 0..added {
|
||||
imported.execute("edit.redo", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(imported.active().unwrap().doc.as_ref(), after_doc.as_ref(), "{depth}/{mode} redo");
|
||||
let saved = saved_items(&mut imported);
|
||||
assert!(!saved[10].enabled);
|
||||
assert!(saved[0].with_dialog);
|
||||
}
|
||||
assert_eq!(pixels(&imported), pixels(&direct), "{depth}/{mode}");
|
||||
assert_eq!(imported.active().unwrap().history.past_len(), direct.active().unwrap().history.past_len());
|
||||
let added = imported.active().unwrap().history.past_len() - history;
|
||||
assert!(added > 0);
|
||||
for _ in 0..added {
|
||||
imported.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(pixels(&imported), before, "{depth}/{mode} undo");
|
||||
let saved = saved_items(&mut imported);
|
||||
assert!(!saved[10].enabled);
|
||||
assert!(saved[0].with_dialog);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -485,6 +485,11 @@ fn settings_at_the_value_the_event_plays_still_export() {
|
||||
/// Each mapped command retains its parameters through its corresponding ATN descriptor.
|
||||
fn every_mapped_command_exports_and_imports_back() {
|
||||
let cases: Vec<(&str, Value)> = vec![
|
||||
(
|
||||
"paint.gradient",
|
||||
json!({"from":[1,1],"to":[6,2],"style":"linear","method":"classic","smoothness":100,
|
||||
"stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[],"opacity":100,"mode":"Normal","reverse":false,"dither":false}),
|
||||
),
|
||||
("filter.render.clouds", json!({})),
|
||||
("filter.render.differenceClouds", json!({})),
|
||||
("filter.render.fibers", json!({"variance": 20, "strength": 7, "seed": -123})),
|
||||
@@ -495,6 +500,11 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"intersect"})),
|
||||
("edit.pasteSpecial.pasteInto", json!({})),
|
||||
("edit.pasteSpecial.pasteOutside", json!({})),
|
||||
(
|
||||
"edit.contentAwareFill",
|
||||
json!({"sampling":"rectangular","sampleAllLayers":true,"colorAdaptation":"default",
|
||||
"rotationAdaptation":"none","scale":true,"mirror":false,"output":"new"}),
|
||||
),
|
||||
("edit.fade", json!({"opacity":50,"mode":"Multiply"})),
|
||||
("layer.linkLayers", json!({})),
|
||||
("layer.distribute.leftEdges", json!({})),
|
||||
@@ -619,6 +629,7 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("image.duplicate", json!({"mergedOnly": true})),
|
||||
("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})),
|
||||
("type.convertToShape", json!({})),
|
||||
("type.createWorkPath", json!({})),
|
||||
("filter.blur.shapeBlur", json!({})),
|
||||
("filter.blur.shapeBlur", json!({"radius": 10})),
|
||||
("layer.new.group", json!({"name": "G"})),
|
||||
@@ -885,6 +896,10 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
}
|
||||
assert_eq!(steps[0].0, *id, "{id} came back as another command");
|
||||
let mut back = steps[0].1.clone();
|
||||
if *id == "paint.gradient" {
|
||||
// Executable values match; independent public tests verify the source carrier.
|
||||
back.as_object_mut().unwrap().remove("__atnClassicGradient");
|
||||
}
|
||||
normalise(&mut back);
|
||||
if *id == "actions.play" || *id == "actions.conditional" {
|
||||
back["set"] = json!("S");
|
||||
|
||||
@@ -435,6 +435,9 @@ fn run_droplet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
join(&first.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(), "droplet-output")
|
||||
});
|
||||
let mut bp = json!({"steps": steps, "input": inputs, "output": output, "format": opts.get("format").cloned().unwrap_or(json!("same"))});
|
||||
if let Some(name) = v.get("action").and_then(|action| action.get("name")).or_else(|| v.get("name")) {
|
||||
bp["action"] = name.clone();
|
||||
}
|
||||
if let Some(q) = opts.get("quality") {
|
||||
bp["quality"] = q.clone();
|
||||
}
|
||||
|
||||
@@ -610,6 +610,8 @@ fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
|
||||
dup.name = doc.copy_name(&src.name);
|
||||
dup.locks = Default::default();
|
||||
let nid = doc.insert_above(Some(id), dup);
|
||||
// A lone copy joins its original's link group, as in the reference application (#11217).
|
||||
crate::layer_multi_cmds::relink_copies(doc, &[nid])?;
|
||||
*active = Some(nid);
|
||||
Ok(nid)
|
||||
})?;
|
||||
|
||||
@@ -17,13 +17,14 @@ fn clip_bytes(s: &Session) -> usize {
|
||||
s.clipboard.as_ref().map_or(0, |c| c.surface.tiles().map(|(_, t)| t.len()).sum())
|
||||
}
|
||||
|
||||
/// Apply the requested irreversible purge and record its boundary so action finalization cannot resurrect undo states.
|
||||
pub(super) fn purge(s: &mut Session, what: &str) -> Result<Value> {
|
||||
let mut freed = 0usize;
|
||||
let mut items: Vec<&str> = Vec::new();
|
||||
if matches!(what, "undo" | "all") && s.active().is_some_and(|d| d.history.can_undo()) {
|
||||
let before = history_bytes(s, false);
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.history.purge_last();
|
||||
st.history.purge_undo();
|
||||
}
|
||||
freed += before.saturating_sub(history_bytes(s, false));
|
||||
items.push("undo");
|
||||
|
||||
@@ -179,6 +179,7 @@ fn same_format_takes_the_file_names_extension() {
|
||||
assert_eq!(same_format("/in/.hidden"), "png");
|
||||
}
|
||||
|
||||
/// Run one source through its action and output pipeline while finalizing only that document's owned history scope.
|
||||
fn process_outputs<'a>(
|
||||
s: &Session,
|
||||
outputs: impl IntoIterator<Item = (&'a str, String)>,
|
||||
@@ -187,11 +188,16 @@ fn process_outputs<'a>(
|
||||
) -> Value {
|
||||
// The scratch sessions have default preferences; a raw opens at the user's Raw Defaults.
|
||||
let raw_ppi = s.prefs().raw_defaults.resolution;
|
||||
let action_prefs = s.prefs().actions.clone();
|
||||
let mut files = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut written = OutputClaims::default();
|
||||
let run = |scratch: &mut Session, out: &str| {
|
||||
scratch.prefs.edit(|prefs| prefs.actions = action_prefs.clone());
|
||||
f(scratch, out)
|
||||
};
|
||||
for (path, out) in outputs {
|
||||
let r = process_one(raw_ppi, path, &out, save, ProcessOptions { format: None, written: Some(&written), ctx: None }, f);
|
||||
let r = process_one(raw_ppi, path, &out, save, ProcessOptions { format: None, written: Some(&written), ctx: None }, &run);
|
||||
match r {
|
||||
Ok(()) => {
|
||||
written.record(&out, path);
|
||||
@@ -478,8 +484,17 @@ pub(crate) fn batch_keeps_step(id: &str) -> bool {
|
||||
(batch_save_point(id) || !crate::actions_cmds::shell_save_command(id)) && !crate::actions_cmds::shell_view_command(id)
|
||||
}
|
||||
|
||||
/// Execute the selected source set with per-document action scopes and restore the caller's library on every exit.
|
||||
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.automate.batch";
|
||||
let action_name = s
|
||||
.action_history
|
||||
.as_ref()
|
||||
.map(|group| group.name.clone())
|
||||
.or_else(|| s.actions.playback_stack.first().cloned())
|
||||
.or_else(|| p.get("action").and_then(Value::as_str).map(str::to_string))
|
||||
.or_else(|| p.get("action").and_then(|action| action.get("name")).and_then(Value::as_str).map(str::to_string))
|
||||
.unwrap_or_else(|| "Batch".into());
|
||||
let mut steps =
|
||||
parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?;
|
||||
// Each result goes to the destination folder, which overrides the action's recorded Save /
|
||||
@@ -541,18 +556,27 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
if source == "open" {
|
||||
let save = SaveOpts::from_params(s, p, cmd)?;
|
||||
if p.get("calledActions").is_some_and(|value| !value.is_null()) {
|
||||
let previous = std::mem::replace(&mut s.actions.list, actions.list.clone());
|
||||
let result = batch_open_documents(s, &steps, p, &output, &format, &policy, save);
|
||||
s.actions.list = previous;
|
||||
return result;
|
||||
// A top-level open-document batch owns its lazy per-document scope. A batch
|
||||
// inside an action shares that action's scope and must not finish it early.
|
||||
let owns_history = s.action_history.is_none() && s.prefs().actions.create_single_history_state;
|
||||
if owns_history {
|
||||
crate::actions_cmds::begin_document_history(s, &action_name);
|
||||
}
|
||||
return batch_open_documents(s, &steps, p, &output, &format, &policy, save);
|
||||
let previous = p.get("calledActions").is_some_and(|value| !value.is_null()).then(|| std::mem::replace(&mut s.actions.list, actions.list.clone()));
|
||||
let result = batch_open_documents(s, &steps, p, &output, &format, &policy, save);
|
||||
if owns_history {
|
||||
crate::actions_cmds::finish_document_history(s);
|
||||
}
|
||||
if let Some(previous) = previous {
|
||||
s.actions.list = previous;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let names = batch_naming::plan(&inputs, &output, &format, p)?;
|
||||
let save = SaveOpts::from_params(s, p, cmd)?;
|
||||
let authorize = s.authorize;
|
||||
let raw_ppi = s.prefs().raw_defaults.resolution;
|
||||
let action_prefs = s.prefs().actions.clone();
|
||||
let outputs: Vec<(String, String)> = match names {
|
||||
Some(names) => inputs.iter().map(|input| input.path.clone()).zip(names).collect(),
|
||||
None => inputs
|
||||
@@ -570,6 +594,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Each input runs in its own scratch session, so what the played steps spend is carried from
|
||||
// one input to the next (failed or not) and back to this session, not restarted per input.
|
||||
let was_playing = s.actions.playing > 0;
|
||||
#[cfg(test)]
|
||||
let history_observer = s.batch_history_observer;
|
||||
crate::jobs::run(
|
||||
s,
|
||||
"Batch",
|
||||
@@ -580,6 +606,11 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
scratch.authorize = authorize;
|
||||
scratch.actions = actions.clone();
|
||||
scratch.actions.playback_steps = carried.get();
|
||||
scratch.prefs.edit(|prefs| prefs.actions = action_prefs.clone());
|
||||
let owns_history = scratch.action_history.is_none() && scratch.prefs().actions.create_single_history_state;
|
||||
if owns_history {
|
||||
crate::actions_cmds::begin_document_history(scratch, &action_name);
|
||||
}
|
||||
let result = (|| {
|
||||
// A recorded save marks a result. If the action later closes or switches
|
||||
// documents, write the saved snapshot instead of the new active document.
|
||||
@@ -616,6 +647,13 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => Ok(false),
|
||||
}
|
||||
})();
|
||||
if owns_history {
|
||||
crate::actions_cmds::finish_document_history(scratch);
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(observe) = history_observer {
|
||||
observe(scratch);
|
||||
}
|
||||
carried.set(scratch.actions.playback_steps);
|
||||
result
|
||||
};
|
||||
@@ -752,3 +790,243 @@ fn batch_open_documents(
|
||||
result["documents"] = json!(originals.len());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod action_history_tests {
|
||||
use super::*;
|
||||
use crate::actions_cmds::Action;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Observed {
|
||||
id: photocraft_doc::DocId,
|
||||
past: usize,
|
||||
label: String,
|
||||
name: String,
|
||||
undo_name: String,
|
||||
}
|
||||
|
||||
thread_local! { static OBSERVED: RefCell<Vec<Observed>> = const { RefCell::new(Vec::new()) }; }
|
||||
|
||||
/// Inspect the real scratch boundary without retaining or changing the scratch session.
|
||||
fn observe(s: &Session) {
|
||||
assert!(s.action_history.is_none(), "Batch must release the scratch scope on every exit");
|
||||
let st = s.active().unwrap();
|
||||
let mut history = st.history.clone();
|
||||
let (before, _) = history.undo(st.doc.clone()).unwrap();
|
||||
let undo_name = before.layer(st.active_layer.unwrap()).unwrap().name.clone();
|
||||
let (after, _) = history.redo(before).unwrap();
|
||||
assert_eq!(after.as_ref(), st.doc.as_ref(), "redo restores exact document and layer identities");
|
||||
let name = st.doc.layer(st.active_layer.unwrap()).unwrap().name.clone();
|
||||
OBSERVED.with(|seen| {
|
||||
seen.borrow_mut().push(Observed { id: st.doc.id, past: st.history.past_len(), label: st.history.undo_label().unwrap().into(), name, undo_name })
|
||||
});
|
||||
}
|
||||
|
||||
/// Batch and droplets keep one independent named entry per input, including nested and stopped runs.
|
||||
#[test]
|
||||
fn batch_and_droplet_document_history_boundaries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![255; 48]).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let paths: Vec<_> = ["a.png", "b.png"]
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let path = dir.path().join(name);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
for (case, single, nested, stopped, droplet) in [
|
||||
("batch", true, false, false, false),
|
||||
("off", false, false, false, false),
|
||||
("nested", true, true, false, false),
|
||||
("stopped", true, false, true, false),
|
||||
("droplet", true, false, false, true),
|
||||
] {
|
||||
OBSERVED.with(|seen| seen.borrow_mut().clear());
|
||||
let mut s = Session::new();
|
||||
s.batch_history_observer = Some(observe);
|
||||
s.execute("actions.setHistory", json!({"singleState":single})).unwrap();
|
||||
let mut steps = vec![("layer.renameLayer".into(), json!({"name":"Prefix"}))];
|
||||
if stopped {
|
||||
steps.push(("actions.stopMessage".into(), json!({"message":"Stop here"})));
|
||||
}
|
||||
steps.push(("layer.renameLayer".into(), json!({"name":"Final"})));
|
||||
let action_steps = if nested {
|
||||
s.actions.list.push(Action::new("Child", steps));
|
||||
vec![("actions.play".to_string(), json!({"action":"Child"}))]
|
||||
} else {
|
||||
steps
|
||||
};
|
||||
let output = dir.path().join(case);
|
||||
let result = if droplet {
|
||||
let path = dir.path().join("named.pcdroplet");
|
||||
s.execute("file.automate.createDroplet", json!({"path":path,"name":"Named","steps":action_steps,"output":output,"shim":false})).unwrap();
|
||||
s.execute("file.automate.runDroplet", json!({"droplet":path,"input":paths})).unwrap()
|
||||
} else {
|
||||
s.execute("file.automate.batch", json!({"action":"Named","steps":action_steps,"input":paths,"output":output})).unwrap()
|
||||
};
|
||||
assert_eq!(result["errors"].as_array().unwrap().len(), if stopped { 2 } else { 0 }, "{case}: {result}");
|
||||
OBSERVED.with(|seen| {
|
||||
let seen = seen.borrow();
|
||||
assert_eq!(seen.len(), 2, "{case}");
|
||||
assert_ne!(seen[0].id, seen[1].id, "inputs have separate editing identities");
|
||||
for entry in seen.iter() {
|
||||
assert_eq!(entry.past, if single { 1 } else { 2 }, "{case}: {entry:?}");
|
||||
if single {
|
||||
assert_eq!(entry.label, "Action: Named", "{case}");
|
||||
}
|
||||
assert_eq!(entry.name, if stopped { "Prefix" } else { "Final" }, "{case}");
|
||||
if !single {
|
||||
assert_eq!(entry.undo_name, "Prefix", "OFF undoes only the last step");
|
||||
} else {
|
||||
assert_ne!(entry.undo_name, "Prefix", "ON undoes the whole play");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Batch over open documents must preserve an enclosing action scope and finish its own document scopes.
|
||||
fn opened_document_batch_keeps_top_level_and_parent_history_boundaries() {
|
||||
for single in [false, true] {
|
||||
for parent in [false, true] {
|
||||
let mut s = Session::new();
|
||||
for _ in 0..2 {
|
||||
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
|
||||
}
|
||||
s.execute("actions.setHistory", json!({"singleState":single})).unwrap();
|
||||
let before: Vec<_> = s.documents().iter().map(|st| (st.doc.clone(), st.history.past_len())).collect();
|
||||
let params = json!({"source":"open","action":"Opened batch","steps":[
|
||||
["layer.renameLayer",{"name":"Prefix"}], ["layer.renameLayer",{"name":"Final"}]
|
||||
]});
|
||||
if parent {
|
||||
s.actions.list.push(Action::new(
|
||||
"Parent",
|
||||
vec![
|
||||
("layer.renameLayer".into(), json!({"name":"Before batch"})),
|
||||
("file.automate.batch".into(), params),
|
||||
("layer.renameLayer".into(), json!({"name":"After batch"})),
|
||||
],
|
||||
));
|
||||
let result = s.execute("actions.play", json!({"action":"Parent"})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
} else {
|
||||
let result = s.execute("file.automate.batch", params).unwrap();
|
||||
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
|
||||
}
|
||||
assert!(s.action_history.is_none());
|
||||
for (index, (original, past)) in before.iter().enumerate() {
|
||||
s.set_active(index);
|
||||
let st = s.active().unwrap();
|
||||
let after = st.doc.clone();
|
||||
assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, if parent && index == 1 { "After batch" } else { "Final" });
|
||||
let steps = if parent && index == 1 { 4 } else { 2 };
|
||||
assert_eq!(st.history.past_len(), past + if single { 1 } else { steps });
|
||||
if single {
|
||||
assert_eq!(st.history.undo_label(), Some(if parent { "Action: Parent" } else { "Action: Opened batch" }));
|
||||
}
|
||||
assert!(s.undo());
|
||||
if single {
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), original.as_ref());
|
||||
} else {
|
||||
assert_ne!(s.active().unwrap().doc.as_ref(), original.as_ref());
|
||||
}
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A Batch failure restores the library and leaves the caller's active history scope owned by the caller.
|
||||
fn opened_document_batch_error_restores_library_without_finishing_parent() {
|
||||
for parent in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
|
||||
s.execute("actions.setHistory", json!({"singleState":true})).unwrap();
|
||||
s.actions.list.push(Action::new("Original", vec![]));
|
||||
let library = serde_json::to_value(&s.actions.list).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
if parent {
|
||||
crate::actions_cmds::begin_document_history(&mut s, "Parent");
|
||||
}
|
||||
let result = s
|
||||
.execute(
|
||||
"file.automate.batch",
|
||||
json!({
|
||||
"source":"open","action":"Broken","calledActions":[Action::new("Foreign", vec![])],
|
||||
"steps":[["layer.renameLayer",{"name":"Prefix"}],["layer.renameLayer",{"name":false}]]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}");
|
||||
assert_eq!(serde_json::to_value(&s.actions.list).unwrap(), library);
|
||||
if parent {
|
||||
assert_eq!(s.action_history.as_ref().unwrap().name, "Parent");
|
||||
s.execute("layer.renameLayer", json!({"name":"Parent tail"})).unwrap();
|
||||
crate::actions_cmds::finish_document_history(&mut s);
|
||||
} else {
|
||||
assert!(s.action_history.is_none());
|
||||
}
|
||||
let st = s.active().unwrap();
|
||||
let after = st.doc.clone();
|
||||
assert_eq!(st.history.undo_label(), Some(if parent { "Action: Parent" } else { "Action: Broken" }));
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! { static CANCEL_CONTEXT: RefCell<Option<crate::jobs::JobCtx>> = const { RefCell::new(None) }; }
|
||||
|
||||
fn cancel_after_prefix(id: &str, params: &Value) -> Result<()> {
|
||||
if id == "layer.renameLayer" && params["name"] == "Cancel here" {
|
||||
CANCEL_CONTEXT.with(|ctx| ctx.borrow().as_ref().unwrap().cancel());
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Cancellation after a completed edit retains an undoable prefix without leaving a Batch-owned scope open.
|
||||
fn cancelled_folder_batch_finalizes_its_completed_prefix() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![255; 48]).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let input = dir.path().join("input.png");
|
||||
std::fs::write(&input, bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("actions.setHistory", json!({"singleState":true})).unwrap();
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
s.set_inline_job_ctx(Some(ctx.clone()));
|
||||
CANCEL_CONTEXT.with(|slot| *slot.borrow_mut() = Some(ctx.clone()));
|
||||
OBSERVED.with(|seen| seen.borrow_mut().clear());
|
||||
s.authorize = Some(cancel_after_prefix);
|
||||
s.batch_history_observer = Some(observe);
|
||||
let result = s
|
||||
.execute(
|
||||
"file.automate.batch",
|
||||
json!({"action":"Cancelled batch","input":[input],"output":dir.path().join("out"),
|
||||
"steps":[["layer.renameLayer",{"name":"Prefix"}],["layer.renameLayer",{"name":"Cancel here"}]]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
CANCEL_CONTEXT.with(|slot| *slot.borrow_mut() = None);
|
||||
assert!(ctx.cancelled());
|
||||
assert_eq!(result["cancelled"], true, "{result}");
|
||||
assert!(result["files"].as_array().unwrap().is_empty(), "{result}");
|
||||
OBSERVED.with(|seen| {
|
||||
let seen = seen.borrow();
|
||||
assert_eq!(seen.len(), 1);
|
||||
assert_eq!(seen[0].past, 1);
|
||||
assert_eq!(seen[0].label, "Action: Cancelled batch");
|
||||
assert_eq!(seen[0].name, "Prefix");
|
||||
assert_ne!(seen[0].undo_name, "Prefix");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,20 +69,20 @@ fn delete_state(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if i == 0 {
|
||||
return Err(bad(CMD, "the oldest state can't be deleted"));
|
||||
}
|
||||
let rows = crate::actions_cmds::history_rows(s);
|
||||
if non_linear {
|
||||
crate::history_options_cmds::delete_state(s, i, current)?;
|
||||
let entries = s.active().ok_or(EngineError::NoDocument)?.history.entries();
|
||||
return Ok(json!({ "states": entries.len(), "current": entries.last() }));
|
||||
}
|
||||
// Step back to the state before `i`; the steps from `i` on are then redo states.
|
||||
for _ in i..=current {
|
||||
if !s.undo() {
|
||||
return Err(EngineError::Other("the history can't step back right now".into()));
|
||||
} else {
|
||||
// Step back to the state before `i`; the steps from `i` on are then redo states.
|
||||
for _ in i..=current {
|
||||
if !s.undo() {
|
||||
return Err(EngineError::Other("the history can't step back right now".into()));
|
||||
}
|
||||
}
|
||||
s.active_mut().ok_or(EngineError::NoDocument)?.history.clear_redo();
|
||||
}
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
st.history.clear_redo();
|
||||
let entries = st.history.entries();
|
||||
crate::actions_cmds::reconcile_history_removal(s, &rows);
|
||||
let entries = s.active().ok_or(EngineError::NoDocument)?.history.entries();
|
||||
Ok(json!({ "states": entries.len(), "current": entries.last() }))
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ pub enum HistorySource {
|
||||
|
||||
/// The document's History Brush source, `None` once the source state or snapshot is gone.
|
||||
pub fn source(st: &DocState) -> Option<HistorySource> {
|
||||
match st.source_snapshot {
|
||||
match st.source_snapshot.or_else(|| st.history.brush_source_snapshot()) {
|
||||
Some(id) => st.snapshots.iter().any(|s| s.id == id).then_some(HistorySource::Snapshot(id)),
|
||||
None => st.history.brush_source().map(HistorySource::State),
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub use moves::{move_targets, move_to, moved, moved_target, moves_mask_alone, pr
|
||||
use selection::{doc_state, find_layers, select_all_layers, select_linked};
|
||||
pub(crate) use selection::{mask_targets, recorded_step, reselect, targets, top_level};
|
||||
pub use selection::{select, selected, set_selection};
|
||||
pub(crate) use structure::{check_group_depth, deletion_neighbours};
|
||||
pub(crate) use structure::{check_group_depth, deletion_neighbours, relink_copies};
|
||||
pub use structure::{delete_selected, duplicate_selected, group_layers, set_visible_selected};
|
||||
use structure::{link_layers, lock_layers, merge_layers, rename_layer, reverse};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::selection::{reselect, same_named_targets, selected, top_level};
|
||||
use super::structure::check_group_depth;
|
||||
use super::structure::{check_group_depth, relink_copies};
|
||||
use super::{Document, EngineError, Layer, LayerContent, LayerId, Result, Session, Value, bad, int_i32, json};
|
||||
|
||||
/// A Layers-panel drag of the selected layer records its drop target by name (and which of
|
||||
@@ -379,6 +379,11 @@ fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: &
|
||||
siblings.splice(at..at, moved);
|
||||
}
|
||||
check_group_depth(doc, "Reorder Layer")?;
|
||||
// A copy is a new layer: it starts unlinked from its original, as a Duplicate does
|
||||
// (#11217); copies of a whole linked set stay linked to each other under a fresh group.
|
||||
if copy {
|
||||
relink_copies(doc, &copies)?;
|
||||
}
|
||||
// The layers now sit in their new parents; carry each across to its new board. Copies keep the
|
||||
// order of `moved_ids`, so `from` lines up with either list.
|
||||
let landed: &[LayerId] = if copy { &copies } else { moved_ids };
|
||||
|
||||
@@ -287,6 +287,7 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul
|
||||
}
|
||||
copies.push(nid);
|
||||
}
|
||||
relink_copies(doc, &copies)?;
|
||||
let a = new_active.or(copies.last().copied());
|
||||
*active = a;
|
||||
Ok((copies, a))
|
||||
@@ -296,6 +297,49 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul
|
||||
Ok(json!({"layers": out, "layer": active.map(|l| l.0)}))
|
||||
}
|
||||
|
||||
/// Link groups of fresh copies, as the reference application does (measured, #11217): a copy of
|
||||
/// one layer alone (Duplicate, Layer via Copy) joins its original's link group. With several
|
||||
/// layers copied, the copies of several layers of one link group are linked to each other under a
|
||||
/// group of their own, as `layer_copy_cmds::adapt_copies` remaps copies landing in another
|
||||
/// document, and a copy that is the only one from its link group is unlinked. `copies` are the
|
||||
/// copies just inserted in `doc` (the layers in copied groups are remapped with them); the
|
||||
/// originals keep their groups.
|
||||
pub(crate) fn relink_copies(doc: &mut Document, copies: &[LayerId]) -> Result<()> {
|
||||
// One layer copied on its own keeps the link group it was copied with.
|
||||
if let [only] = copies
|
||||
&& doc.layer(*only).is_some_and(|l| !matches!(l.content, LayerContent::Group(_)))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Every copied layer with the link group it was copied with. An explicit stack rather than
|
||||
// recursion: documents can nest deeply.
|
||||
let mut linked: Vec<(LayerId, u64)> = Vec::new();
|
||||
let mut stack: Vec<&Layer> = copies.iter().filter_map(|id| doc.layer(*id)).collect();
|
||||
while let Some(l) = stack.pop() {
|
||||
if let Some(group) = l.link_group {
|
||||
linked.push((l.id, group));
|
||||
}
|
||||
if let LayerContent::Group(g) = &l.content {
|
||||
stack.extend(g.children.iter());
|
||||
}
|
||||
}
|
||||
let mut groups: Vec<u64> = linked.iter().map(|(_, group)| *group).collect();
|
||||
groups.sort_unstable();
|
||||
groups.dedup();
|
||||
for group in groups {
|
||||
let members: Vec<LayerId> = linked.iter().filter(|(_, g)| *g == group).map(|(id, _)| *id).collect();
|
||||
// The id is picked after the previous group's copies took theirs, so no two share one.
|
||||
let fresh =
|
||||
if members.len() < 2 { None } else { Some(doc.unused_link_group().ok_or_else(|| EngineError::Other("no free link group id is left".into()))?) };
|
||||
for id in members {
|
||||
if let Some(l) = doc.layer_mut(id) {
|
||||
l.link_group = fresh;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show/hide every selected layer in one step.
|
||||
pub fn set_visible_selected(s: &mut Session, visible: bool) -> Result<Value> {
|
||||
let sel = selected(s);
|
||||
|
||||
@@ -1456,6 +1456,93 @@ fn inspect_reports_selection() {
|
||||
assert_eq!(v["layers"][0]["selected"], true);
|
||||
}
|
||||
|
||||
fn link_group(s: &Session, id: LayerId) -> Option<u64> {
|
||||
doc(s).layer(id).unwrap().link_group
|
||||
}
|
||||
|
||||
/// A and B linked (red blocks), C unlinked: (session, A, B, C, A and B's link group).
|
||||
fn linked_pair() -> (Session, LayerId, LayerId, LayerId, Option<u64>) {
|
||||
let mut s = session(8);
|
||||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||||
let b = rect_layer(&mut s, Rect::new(10, 0, 15, 5));
|
||||
let c = rect_layer(&mut s, Rect::new(20, 0, 25, 5));
|
||||
select_all(&mut s, &[a, b]);
|
||||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||||
let group = link_group(&s, a);
|
||||
assert!(group.is_some() && link_group(&s, b) == group);
|
||||
(s, a, b, c, group)
|
||||
}
|
||||
|
||||
/// #11217: a copy of one linked layer alone joins its original's link group; copied together with
|
||||
/// an unlinked layer it is unlinked (both measured in the reference application). The originals
|
||||
/// stay linked.
|
||||
#[test]
|
||||
fn duplicating_one_linked_layer_keeps_the_copy_in_its_link_group() {
|
||||
let (mut s, a, b, c, group) = linked_pair();
|
||||
// One layer, and with an unlinked layer beside it (several selected).
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
let copy = LayerId(s.execute("layer.duplicate", json!({"inPlace": true})).unwrap()["layer"].as_u64().unwrap());
|
||||
assert_eq!(link_group(&s, copy), group, "the lone copy joins the originals' link group");
|
||||
select_all(&mut s, &[a, c]);
|
||||
let r = s.execute("layer.duplicate", json!({})).unwrap();
|
||||
let copies: Vec<LayerId> = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(copies.len(), 2);
|
||||
assert!(copies.iter().all(|id| link_group(&s, *id).is_none()), "a lone copy of a group has nothing to stay linked to");
|
||||
assert_eq!((link_group(&s, a), link_group(&s, b), link_group(&s, c)), (group, group, None), "the originals keep their links");
|
||||
}
|
||||
|
||||
/// #11217 review: copy-dragging layers in the panel goes through `apply_move`, which must
|
||||
/// relink its copies the way Duplicate does: a lone copy joins its original's link group, a
|
||||
/// copied pair is linked to each other under a fresh group, not to the originals.
|
||||
#[test]
|
||||
fn copy_dragging_linked_layers_relinks_the_copies() {
|
||||
let (mut s, a, b, c, group) = linked_pair();
|
||||
let r = s.execute("layer.moveTo", json!({"layer": a.0, "target": c.0, "position": "above", "copy": true})).unwrap();
|
||||
let copy = LayerId(r["layer"].as_u64().unwrap());
|
||||
assert_eq!(link_group(&s, copy), group, "the lone copy-dragged copy joins the originals' link group");
|
||||
assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep their links");
|
||||
|
||||
let r = s.execute("layer.moveTo", json!({"layers": [a.0, b.0], "target": c.0, "position": "above", "copy": true})).unwrap();
|
||||
let copies: Vec<LayerId> = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(copies.len(), 2);
|
||||
let fresh = link_group(&s, copies[0]);
|
||||
assert!(fresh.is_some() && fresh != group && link_group(&s, copies[1]) == fresh, "the copied pair is linked to each other under a fresh group");
|
||||
assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep their links");
|
||||
}
|
||||
|
||||
/// #11217 review: copies of a linked pair stay linked to each other under a new group, not the
|
||||
/// originals', which keep theirs.
|
||||
#[test]
|
||||
fn duplicating_a_linked_pair_links_the_copies_to_each_other() {
|
||||
let (mut s, a, b, _, group) = linked_pair();
|
||||
select_all(&mut s, &[a, b]);
|
||||
let r = s.execute("layer.duplicate", json!({})).unwrap();
|
||||
let copies: Vec<LayerId> = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(copies.len(), 2);
|
||||
let fresh = link_group(&s, copies[0]);
|
||||
assert!(fresh.is_some() && fresh != group, "a group of their own: {fresh:?} vs {group:?}");
|
||||
assert_eq!(link_group(&s, copies[1]), fresh, "the copies are linked to each other");
|
||||
assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep theirs");
|
||||
// Moving a copy moves its partner copy only.
|
||||
let (a0, b0) = (bounds(&s, a), bounds(&s, b));
|
||||
s.execute("layer.translate", json!({"layer": copies[0].0, "dx": 3})).unwrap();
|
||||
assert_eq!(bounds(&s, copies[1]), b0.translate(3, 0));
|
||||
assert_eq!((bounds(&s, a), bounds(&s, b)), (a0, b0));
|
||||
}
|
||||
|
||||
/// #11217: Layer via Copy of one linked layer (no selection) joins its original's link group too
|
||||
/// (measured): the same rule as `layer.duplicate`, on its own code path in `layer_via`.
|
||||
#[test]
|
||||
fn layer_via_copy_of_one_linked_layer_keeps_the_copy_in_its_link_group() {
|
||||
let (mut s, a, b, _, group) = linked_pair();
|
||||
let layers = doc(&s).layers.len();
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
let copy = LayerId(s.execute("layer.new.layerViaCopy", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
assert_eq!(doc(&s).layers.len(), layers + 1, "only the active layer is copied");
|
||||
assert_eq!(link_group(&s, copy), group, "the lone copy joins the originals' link group");
|
||||
assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep their links");
|
||||
}
|
||||
|
||||
/// #7538: `selection: true` moves the selection with the layers in the same step, as the Move
|
||||
/// tool does with several layers selected; without it the selection stays.
|
||||
#[test]
|
||||
|
||||
@@ -283,6 +283,16 @@ pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option<La
|
||||
if ids.is_empty() { active.into_iter().collect() } else { ids }
|
||||
}
|
||||
|
||||
/// The layers one box over `targets` moves: `targets` with the layers linked to them, as the Move
|
||||
/// tool takes them along. In Photoshop transforming one linked layer transforms the whole link
|
||||
/// group (#11217). A linked Background joins only when it is a target itself: `targets_of`
|
||||
/// leaves it out of a box over other layers, and a link doesn't bring it back in.
|
||||
pub fn box_targets(doc: &Document, targets: &[LayerId]) -> Vec<LayerId> {
|
||||
let mut ids = crate::layer_multi_cmds::move_targets(doc, targets);
|
||||
ids.retain(|id| targets.contains(id) || !doc.is_background(*id));
|
||||
ids
|
||||
}
|
||||
|
||||
/// The `layers` param: the layers one box transforms together (at most 10000, each in the
|
||||
/// document), without those a listed group carries. `None` when it isn't given.
|
||||
fn layers_param(doc: &Document, p: &Value) -> Result<Option<Vec<LayerId>>> {
|
||||
@@ -816,8 +826,9 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, false) if many.is_some() => None,
|
||||
(None, false) => return Err(EngineError::Other("no active layer".into())),
|
||||
};
|
||||
let listed = many.is_some();
|
||||
// The layers the box moves: those listed, the one named, or else the layer selection (#6501).
|
||||
let ids: Vec<LayerId> = match (many, explicit) {
|
||||
let mut ids: Vec<LayerId> = match (many, explicit) {
|
||||
(Some(ids), _) => ids,
|
||||
_ if lone.is_some() => Vec::new(),
|
||||
(None, Some(id)) => vec![id],
|
||||
@@ -826,6 +837,13 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) {
|
||||
return Err(EngineError::NoLayer(*missing));
|
||||
}
|
||||
// The named layer or the selection takes its linked layers along, as the Move tool does: in
|
||||
// Photoshop transforming one linked layer transforms the whole link group (#11217), but not a
|
||||
// linked Background it didn't target ([`box_targets`]). A caller listing `layers` gets
|
||||
// exactly those.
|
||||
if !listed {
|
||||
ids = box_targets(&st.doc, &ids);
|
||||
}
|
||||
let rect = match p.get("rect").and_then(Value::as_array) {
|
||||
Some(r) if r.len() == 4 => {
|
||||
let v: Vec<f64> = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect();
|
||||
|
||||
@@ -273,6 +273,157 @@ fn a_recorded_transform_of_the_selected_layers_replays_on_the_selection_in_anoth
|
||||
assert_eq!(bounds(&s, spare), spare0, "the unselected layer stayed");
|
||||
}
|
||||
|
||||
/// A 200 x 200 document with three 20 x 20 blocks, "A" (x 10..30), "B" (x 60..80) and "C"
|
||||
/// (x 120..140), all at y 10..30. A and B are linked; C isn't. Only A is selected, and active.
|
||||
fn linked_layers() -> (Session, LayerId, LayerId, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap();
|
||||
let mut ids = Vec::new();
|
||||
for (name, x) in [("A", 10), ("B", 60), ("C", 120)] {
|
||||
let id = LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.edit("paint", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x, 10, x + 20, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let (a, b, c) = (ids[0], ids[1], ids[2]);
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
(s, a, b, c)
|
||||
}
|
||||
|
||||
fn moved_layers(r: &Value) -> Vec<u64> {
|
||||
let mut ids: Vec<u64> = serde_json::from_value(r["layers"].clone()).unwrap();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
|
||||
/// `got` is `want` give or take a pixel of resampling on each edge.
|
||||
fn assert_near(got: Rect, want: Rect) {
|
||||
let close = [(got.x0, want.x0), (got.y0, want.y0), (got.x1, want.x1), (got.y1, want.y1)].iter().all(|(g, w)| (g - w).abs() <= 1);
|
||||
assert!(close, "{got:?} is not {want:?}");
|
||||
}
|
||||
|
||||
/// A 200 % scale of A and B about the centre (45, 20) of their joined frame.
|
||||
fn scaled_pair(s: &Session, a: LayerId, b: LayerId) {
|
||||
assert_near(bounds(s, a), Rect::new(-25, 0, 15, 40));
|
||||
assert_near(bounds(s, b), Rect::new(75, 0, 115, 40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforming_one_linked_layer_transforms_its_link_group() {
|
||||
// Before #11217 only A scaled; Photoshop scales every layer linked to it, by the same box.
|
||||
let (mut s, a, b, c) = linked_layers();
|
||||
let c0 = bounds(&s, c);
|
||||
let r = s.execute("edit.transform", json!({"layer": a.0, "scaleX": 200, "scaleY": 200, "interpolation": "nearest"})).unwrap();
|
||||
assert_eq!(moved_layers(&r), [a.0, b.0]);
|
||||
let rect: Vec<f64> = serde_json::from_value(r["rect"].clone()).unwrap();
|
||||
assert_eq!(rect, [10.0, 10.0, 80.0, 30.0], "the frame covers the link group");
|
||||
scaled_pair(&s, a, b);
|
||||
assert_eq!(bounds(&s, c), c0, "an unlinked layer stays put");
|
||||
s.undo();
|
||||
assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(10, 10, 30, 30), Rect::new(60, 10, 80, 30)), "one Undo takes both back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforming_the_selected_linked_layer_transforms_its_link_group() {
|
||||
let (mut s, a, b, c) = linked_layers();
|
||||
let c0 = bounds(&s, c);
|
||||
// No `layer`: the layer selection (A alone) is the target.
|
||||
let r = s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 200, "interpolation": "nearest"})).unwrap();
|
||||
assert_eq!(moved_layers(&r), [a.0, b.0]);
|
||||
scaled_pair(&s, a, b);
|
||||
assert_eq!(bounds(&s, c), c0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_layers_do_not_pull_in_their_linked_layers() {
|
||||
let (mut s, a, b, _) = linked_layers();
|
||||
let r = s.execute("edit.transform", json!({"layers": [a.0], "matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
|
||||
assert_eq!(moved_layers(&r), [a.0]);
|
||||
assert_eq!(bounds(&s, a), Rect::new(15, 10, 35, 30));
|
||||
assert_eq!(bounds(&s, b), Rect::new(60, 10, 80, 30), "listed layers are exactly the ones moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_linked_layer_by_free_transform_moves_each_linked_layer_once() {
|
||||
let (mut s, a, b, c) = linked_layers();
|
||||
let c0 = bounds(&s, c);
|
||||
// A and B both selected, and linked: each moves by the offset once, not once per link.
|
||||
s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 30, 40]})).unwrap();
|
||||
assert_eq!(moved_layers(&r), [a.0, b.0]);
|
||||
assert_eq!(bounds(&s, a), Rect::new(40, 50, 60, 70));
|
||||
assert_eq!(bounds(&s, b), Rect::new(90, 50, 110, 70));
|
||||
assert_eq!(bounds(&s, c), c0);
|
||||
// And from A alone, through the active layer.
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("edit.transform", json!({"dx": -30, "dy": -40})).unwrap();
|
||||
assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(10, 10, 30, 30), Rect::new(60, 10, 80, 30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_linked_layer_refuses_the_transform_of_its_link_group() {
|
||||
// As with several selected layers: one that can't move leaves every layer as it was.
|
||||
let (mut s, a, b, _) = linked_layers();
|
||||
s.execute("layer.setProps", json!({"layer": b.0, "locks": {"position": true}})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let (before, revision) = (st.doc.clone(), st.revision);
|
||||
let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap_err().to_string();
|
||||
assert!(err.contains("locked"), "{err}");
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc == before && st.revision == revision);
|
||||
}
|
||||
|
||||
/// The Background (white, 200 x 200) linked to "A", a red block at x 10..30, y 10..30; A is
|
||||
/// selected, and active.
|
||||
fn background_linked_to_a_layer() -> (Session, LayerId, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap();
|
||||
let bg = s.active().unwrap().doc.layers[0].id;
|
||||
let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.edit("paint", |doc, _| {
|
||||
doc.layer_mut(a).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
(s, bg, a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_background_stays_out_of_a_transform_of_its_partner() {
|
||||
// #11217 review: the link group widening must not pull in a Background the transform didn't
|
||||
// target, any more than a selection of several does.
|
||||
let (mut s, bg, a) = background_linked_to_a_layer();
|
||||
for p in [json!({"matrix": [1, 0, 0, 1, 5, 0]}), json!({"layer": a.0, "matrix": [1, 0, 0, 1, 5, 0]})] {
|
||||
let r = s.execute("edit.transform", p.clone()).unwrap();
|
||||
assert_eq!(moved_layers(&r), [a.0], "{p}");
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc.is_background(bg), "{p}: still the Background");
|
||||
assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200), "{p}: and where it was");
|
||||
}
|
||||
assert_eq!(bounds(&s, a), Rect::new(20, 10, 40, 30), "A moved both times");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_of_a_linked_background_still_transforms_it() {
|
||||
let (mut s, bg, a) = background_linked_to_a_layer();
|
||||
let r = s.execute("edit.transform", json!({"layer": bg.0, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
|
||||
assert!(moved_layers(&r).contains(&bg.0), "the targeted Background moves: {r}");
|
||||
let st = s.active().unwrap();
|
||||
assert!(!st.doc.is_background(bg), "turned into a normal layer first, as in Photoshop");
|
||||
assert_eq!(bounds(&s, a), Rect::new(15, 10, 35, 30), "with the layer linked to it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_transform_of_other_layers_than_the_selection_keeps_their_ids() {
|
||||
let (mut s, paint, _) = two_layers();
|
||||
|
||||
@@ -205,6 +205,13 @@ pub struct Session {
|
||||
typed_layer: Option<(photocraft_doc::DocId, LayerId)>,
|
||||
/// Coalescing key of the command being executed.
|
||||
coalesce_request: Option<String>,
|
||||
/// The outer action's original document/history states, independent of command transactions.
|
||||
action_history: Option<actions_cmds::ActionHistory>,
|
||||
/// Weak leases expire when every copy of a suspended continuation is abandoned.
|
||||
paused_action_history: Vec<std::sync::Weak<()>>,
|
||||
/// Tests observe the actual scratch-session boundary without retaining it or changing output.
|
||||
#[cfg(test)]
|
||||
batch_history_observer: Option<fn(&Session)>,
|
||||
/// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop).
|
||||
pub clipboard: Option<edit_cmds::Clip>,
|
||||
/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options
|
||||
|
||||
@@ -1085,6 +1085,19 @@ impl Default for Integrations {
|
||||
}
|
||||
}
|
||||
|
||||
/// Actions panel Playback Options. These are saved independently of an action library.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Actions {
|
||||
pub create_single_history_state: bool,
|
||||
}
|
||||
|
||||
impl Default for Actions {
|
||||
fn default() -> Self {
|
||||
Self { create_single_history_state: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// The History panel's History Options… (not part of Edit › Preferences in Photoshop either).
|
||||
/// Photoshop's snapshot options come with #3634.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -1169,6 +1182,7 @@ impl LayerMaskOverlay {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Preferences {
|
||||
pub actions: Actions,
|
||||
pub general: General,
|
||||
pub interface: Interface,
|
||||
pub workspace: Workspace,
|
||||
@@ -1564,6 +1578,9 @@ impl Session {
|
||||
} else {
|
||||
next.set(path, value)?;
|
||||
}
|
||||
if next.actions.create_single_history_state != self.prefs().actions.create_single_history_state && crate::actions_cmds::history_policy_in_use(self) {
|
||||
return Err("cannot change action history settings during playback".into());
|
||||
}
|
||||
// Old `prefs.set interface.theme` clients (by path or inside a section object) still
|
||||
// select a visible theme.
|
||||
if path == "interface.theme" || next.interface.theme != self.prefs().interface.theme {
|
||||
|
||||
@@ -56,8 +56,16 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
/// Validate policy-changing resets before mutating either preferences or color settings.
|
||||
fn prefs_reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p.get("path").or_else(|| p.get("section")).and_then(Value::as_str);
|
||||
if !path.is_some_and(|path| path == "colorSettings" || path.starts_with("colorSettings.")) {
|
||||
let mut candidate = s.prefs().clone();
|
||||
candidate.reset(path).map_err(|e| bad("prefs.reset", e))?;
|
||||
if candidate.actions.create_single_history_state != s.prefs().actions.create_single_history_state && crate::actions_cmds::history_policy_in_use(s) {
|
||||
return Err(bad("prefs.reset", "cannot change action history settings during playback"));
|
||||
}
|
||||
}
|
||||
match path {
|
||||
Some("colorSettings") => s.color.settings = Default::default(),
|
||||
// One colour setting: `Preferences` has no `colorSettings`, so take the default
|
||||
|
||||
@@ -243,6 +243,9 @@ impl Session {
|
||||
/// [`Self::edit`], recording a history state only when `record` is set. An unrecorded change
|
||||
/// still marks the document modified and leaves the redo states alone.
|
||||
pub(crate) fn edit_with<R>(&mut self, label: &str, record: bool, f: impl FnOnce(&mut Document, &mut Option<LayerId>) -> Result<R>) -> Result<R> {
|
||||
if record {
|
||||
crate::actions_cmds::before_action_edit(self);
|
||||
}
|
||||
// Every mutation path (linked-file refresh, transform, place) re-renders smart objects
|
||||
// through this entry; the requesting session's authority must reach those renders.
|
||||
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
|
||||
|
||||
@@ -2,11 +2,17 @@ use super::documents::fix_active;
|
||||
use super::{Arc, DocState, Document, LayerId, LayerTarget, Session, channel_cmds, layer_multi_cmds};
|
||||
|
||||
impl Session {
|
||||
/// Undo a document step or restore Clear History's rows and source without editing pixels.
|
||||
pub fn undo(&mut self) -> bool {
|
||||
// A background job is computing from the current state: it must not move under it.
|
||||
if self.active_job().is_some() {
|
||||
return false;
|
||||
}
|
||||
// Undo Clear History only restores metadata. It must not establish the new document
|
||||
// baseline that an actual edit after Clear History would create.
|
||||
if !self.active().is_some_and(|st| st.history.is_cleared()) {
|
||||
crate::actions_cmds::before_action_edit(self);
|
||||
}
|
||||
let keep_visibility = !self.visibility_undoable();
|
||||
let Some(st) = self.active_mut() else { return false };
|
||||
st.coalesce = None;
|
||||
@@ -47,10 +53,12 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the next document state and its selection/visibility bookkeeping through the ordinary history path.
|
||||
pub fn redo(&mut self) -> bool {
|
||||
if self.active_job().is_some() {
|
||||
return false;
|
||||
}
|
||||
crate::actions_cmds::before_action_edit(self);
|
||||
let keep_visibility = !self.visibility_undoable();
|
||||
let Some(st) = self.active_mut() else { return false };
|
||||
st.coalesce = None;
|
||||
|
||||
@@ -0,0 +1,903 @@
|
||||
//! Public playback history semantics, independent of the Actions panel implementation.
|
||||
use photocraft_engine::{Session, actions_cmds::Action};
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":4,"height":4,"background":"#804020"})).unwrap();
|
||||
s.actions.list.push(Action::new("Two layers", ["First", "Second"].into_iter().map(|name| ("layer.new.layer".into(), json!({"name":name}))).collect()));
|
||||
s
|
||||
}
|
||||
|
||||
/// Start with distinct older source pixels and a nondefault History Brush source.
|
||||
fn history_control_session(single_state: bool) -> Session {
|
||||
let mut s = session();
|
||||
s.execute("actions.setHistory", json!({"singleState":single_state})).unwrap();
|
||||
s.execute("edit.fill", json!({"color":"#ff0000"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color":"#00ff00"})).unwrap();
|
||||
let current = s.active().unwrap().history.past_len();
|
||||
s.execute("history.setSource", json!({"state":current})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// An explicit clear stays undoable; later edits forget it and group from its boundary.
|
||||
#[test]
|
||||
fn recorded_clear_preserves_its_boundary_and_grouped_suffix() {
|
||||
for single_state in [false, true] {
|
||||
for prefix in [false, true] {
|
||||
for suffix in [false, true] {
|
||||
let mut s = history_control_session(single_state);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let mut steps = Vec::new();
|
||||
if prefix {
|
||||
steps.push(("edit.fill".into(), json!({"color":"#0000ff"})));
|
||||
}
|
||||
steps.push(("history.clear".into(), json!({})));
|
||||
if suffix {
|
||||
steps.extend([("layer.renameLayer".into(), json!({"name":"Suffix one"})), ("layer.renameLayer".into(), json!({"name":"Suffix two"}))]);
|
||||
}
|
||||
s.actions.list = vec![Action::new("Clear", steps)];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
if suffix {
|
||||
assert!(!s.active().unwrap().history.is_cleared());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), if single_state { 1 } else { 2 });
|
||||
assert!(s.undo());
|
||||
if !single_state {
|
||||
assert!(s.undo());
|
||||
}
|
||||
assert!(!s.undo(), "the next edit really forgot the cleared older states");
|
||||
} else {
|
||||
assert!(s.active().unwrap().history.is_cleared());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), 0);
|
||||
assert!(s.undo(), "Undo Clear History restores rows, not older pixels");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &after));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + usize::from(prefix));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Both a surviving old row and an intermediate action row remain usable brush sources.
|
||||
#[test]
|
||||
fn recorded_source_change_preserves_selected_pixels_and_grouping() {
|
||||
for single_state in [false, true] {
|
||||
for prefix in [false, true] {
|
||||
for current in [false, true] {
|
||||
let mut s = history_control_session(single_state);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let mut steps = Vec::new();
|
||||
if prefix {
|
||||
steps.push(("edit.fill".into(), json!({"color":"#0000ff"})));
|
||||
}
|
||||
let selected = if current { past + usize::from(prefix) } else { 1 };
|
||||
steps.extend([
|
||||
("history.setSource".into(), json!({"state":selected})),
|
||||
("edit.fill".into(), json!({"color":"#00ffff"})),
|
||||
("layer.renameLayer".into(), json!({"name":"After source"})),
|
||||
]);
|
||||
s.actions.list = vec![Action::new("Source", steps)];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
let added = if single_state { 1 } else { 2 + usize::from(prefix) };
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + added);
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
for _ in 0..added {
|
||||
assert!(s.undo());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
for _ in 0..added {
|
||||
assert!(s.redo());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
s.execute("edit.fill", json!({"contents":"history"})).unwrap();
|
||||
let pixel = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0);
|
||||
let expected = if !current {
|
||||
[1.0, 0.0, 0.0, 1.0]
|
||||
} else if prefix {
|
||||
[0.0, 0.0, 1.0, 1.0]
|
||||
} else {
|
||||
[0.0, 1.0, 0.0, 1.0]
|
||||
};
|
||||
assert_eq!(pixel, expected, "singleState={single_state}, prefix={prefix}, current={current}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Purge Histories is irreversible; suffix edits keep their grouped Undo/Redo boundary.
|
||||
#[test]
|
||||
fn recorded_purge_histories_does_not_restore_discarded_states() {
|
||||
for single_state in [false, true] {
|
||||
for prefix in [false, true] {
|
||||
let mut s = history_control_session(single_state);
|
||||
let mut steps = Vec::new();
|
||||
if prefix {
|
||||
steps.push(("edit.fill".into(), json!({"color":"#0000ff"})));
|
||||
}
|
||||
steps.extend([
|
||||
("edit.purge.histories".into(), json!({})),
|
||||
("layer.renameLayer".into(), json!({"name":"Suffix one"})),
|
||||
("layer.renameLayer".into(), json!({"name":"Suffix two"})),
|
||||
]);
|
||||
s.actions.list = vec![Action::new("Purge", steps)];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), if single_state { 1 } else { 2 });
|
||||
assert!(s.undo());
|
||||
if !single_state {
|
||||
assert!(s.undo());
|
||||
}
|
||||
assert!(!s.undo(), "Purge Histories cannot be undone");
|
||||
assert!(s.redo());
|
||||
if !single_state {
|
||||
assert!(s.redo());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog identity protection is independent of the optional history grouping.
|
||||
#[test]
|
||||
fn per_step_playback_also_binds_its_dialog_continuation() {
|
||||
use photocraft_engine::actions_cmds;
|
||||
let mut s = history_control_session(false);
|
||||
let action = s.actions.list[0].clone();
|
||||
let recording = actions_cmds::begin_playback(&mut s, &action).unwrap();
|
||||
s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap();
|
||||
let continuation = actions_cmds::suspend_history(&mut s).expect("per-step playback still needs a document/history binding");
|
||||
actions_cmds::finish_playback(&mut s, recording, &action, 0);
|
||||
s.execute("layer.renameLayer", json!({"name":"Unrelated"})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
assert!(continuation.resume(&mut s).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
}
|
||||
|
||||
/// Selecting another existing source snapshot invalidates a pause even when its pixels match.
|
||||
#[test]
|
||||
fn snapshot_source_identity_is_bound_to_the_dialog_continuation() {
|
||||
use photocraft_engine::actions_cmds;
|
||||
for single_state in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("actions.setHistory", json!({"singleState":single_state})).unwrap();
|
||||
let a = s.execute("history.newSnapshot", json!({"name":"Source A"})).unwrap()["snapshot"].clone();
|
||||
let b = s.execute("history.newSnapshot", json!({"name":"Source B"})).unwrap()["snapshot"].clone();
|
||||
s.execute("history.setSource", json!({"snapshot":a})).unwrap();
|
||||
let action = s.actions.list[0].clone();
|
||||
let recording = actions_cmds::begin_playback(&mut s, &action).unwrap();
|
||||
s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap();
|
||||
let continuation = actions_cmds::suspend_history(&mut s).unwrap();
|
||||
actions_cmds::finish_playback(&mut s, recording, &action, 0);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let history = s.active().unwrap().history.entries();
|
||||
s.execute("history.setSource", json!({"snapshot":b})).unwrap();
|
||||
assert!(continuation.resume(&mut s).is_err(), "singleState={single_state}");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.entries(), history);
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo Clear History inside playback restores rows, rather than recording another clear baseline.
|
||||
#[test]
|
||||
fn in_action_undo_clear_restores_history_and_keeps_grouped_image_undo() {
|
||||
for single_state in [false, true] {
|
||||
for prefix in [false, true] {
|
||||
let mut s = history_control_session(single_state);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let rows = s.active().unwrap().history.entries();
|
||||
let mut steps = Vec::new();
|
||||
if prefix {
|
||||
steps.push(("layer.renameLayer".into(), json!({"name":"Prefix"})));
|
||||
}
|
||||
steps.extend([
|
||||
("history.clear".into(), json!({})),
|
||||
("edit.undo".into(), json!({})),
|
||||
("edit.undo".into(), json!({})),
|
||||
("edit.redo".into(), json!({})),
|
||||
]);
|
||||
s.actions.list = vec![Action::new("Undo Clear", steps)];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
assert!(!s.active().unwrap().history.is_cleared(), "singleState={single_state}, prefix={prefix}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + usize::from(prefix));
|
||||
if prefix {
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
} else {
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.entries(), rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A source held by Clear History becomes active on Undo Clear, with no additional image Undo row.
|
||||
#[test]
|
||||
fn cleared_intermediate_source_restores_exact_pixels_after_undo_redo() {
|
||||
for single_state in [false, true] {
|
||||
for undo_clear in [false, true] {
|
||||
let mut s = history_control_session(single_state);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.actions.list = vec![Action::new(
|
||||
"Cleared Source",
|
||||
vec![
|
||||
("edit.fill".into(), json!({"color":"#0000ff"})),
|
||||
("history.setSource".into(), json!({"state":past+1})),
|
||||
("edit.fill".into(), json!({"color":"#00ffff"})),
|
||||
("history.clear".into(), json!({})),
|
||||
],
|
||||
)];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
assert!(s.active().unwrap().history.is_cleared());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), 0);
|
||||
if undo_clear {
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert!(s.undo());
|
||||
assert!(!s.active().unwrap().history.is_cleared());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &after));
|
||||
let added = if single_state { 1 } else { 2 };
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + added);
|
||||
for _ in 0..added {
|
||||
assert!(s.undo());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
for _ in 0..added {
|
||||
assert!(s.redo());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
// Before Undo Clear the source is the current cyan document. Afterwards it is the
|
||||
// explicitly selected intermediate blue document, including after image Undo/Redo.
|
||||
s.execute("edit.fill", json!({"contents":"history"})).unwrap();
|
||||
let pixel = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0);
|
||||
let expected = if undo_clear { [0.0, 0.0, 1.0, 1.0] } else { [0.0, 1.0, 1.0, 1.0] };
|
||||
assert_eq!(pixel, expected, "singleState={single_state}, undoClear={undo_clear}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default playback is one named edit, including the complete Undo and Redo states.
|
||||
#[test]
|
||||
fn default_playback_is_one_named_history_state() {
|
||||
let mut s = session();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let count = s.active().unwrap().history.past_len();
|
||||
let result = s.execute("actions.play", json!({"action":"Two layers"})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), count + 1);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Two layers"));
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(after.layers.len(), before.layers.len() + 2);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert_eq!(s.active().unwrap().history.redo_label(), Some("Action: Two layers"));
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
assert_eq!(s.active().unwrap().doc.layers.iter().map(|l| &l.name).collect::<Vec<_>>(), after.layers.iter().map(|l| &l.name).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Disabling the persisted option retains the existing one-entry-per-step behavior.
|
||||
#[test]
|
||||
fn disabled_single_history_keeps_each_step() {
|
||||
let mut s = session();
|
||||
s.execute("actions.setHistory", json!({"singleState":false})).unwrap();
|
||||
let count = s.active().unwrap().history.past_len();
|
||||
let layers = s.active().unwrap().doc.layers.len();
|
||||
s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), count + 2);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), layers + 1);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), layers);
|
||||
}
|
||||
|
||||
/// Failure commits the completed prefix; an inner play uses its outer action's name and key.
|
||||
#[test]
|
||||
fn failures_and_nested_calls_keep_the_outer_history_entry() {
|
||||
let mut s = session();
|
||||
let layers = s.active().unwrap().doc.layers.len();
|
||||
s.actions.list.push(Action::new(
|
||||
"Outer",
|
||||
vec![
|
||||
("layer.new.layer".into(), json!({"name":"Outer layer"})),
|
||||
("actions.play".into(), json!({"action":0})),
|
||||
("not.a.command".into(), json!({})),
|
||||
("layer.new.layer".into(), json!({"name":"Must not run"})),
|
||||
],
|
||||
));
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
let result = s.execute("actions.play", json!({"action":"Outer"})).unwrap();
|
||||
assert_eq!(result["failed"]["step"], 2);
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), layers + 3);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before + 1);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Outer"));
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), layers);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), layers + 3);
|
||||
}
|
||||
|
||||
/// No-op playback preserves redo, and separate plays never coalesce into one another.
|
||||
#[test]
|
||||
fn no_op_and_repeated_plays_preserve_history_boundaries() {
|
||||
let mut s = session();
|
||||
s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(s.undo());
|
||||
let entries = s.active().unwrap().history.entries();
|
||||
let redo = s.active().unwrap().history.redo_label().map(str::to_owned);
|
||||
s.actions.list.push(Action::new("Empty", vec![]));
|
||||
s.execute("actions.play", json!({"action":"Empty"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.entries(), entries);
|
||||
assert_eq!(s.active().unwrap().history.redo_label(), redo.as_deref());
|
||||
assert!(s.redo());
|
||||
let count = s.active().unwrap().history.past_len();
|
||||
let layers = s.active().unwrap().doc.layers.len();
|
||||
s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), count + 1);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), layers);
|
||||
}
|
||||
|
||||
/// Older preference files default on; an explicit off survives the public save/load path.
|
||||
#[test]
|
||||
fn action_history_setting_is_persisted_and_rejects_wrong_types() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.prefs().actions.create_single_history_state);
|
||||
assert!(s.execute("actions.setHistory", json!({"singleState":"false"})).is_err());
|
||||
assert!(s.prefs().actions.create_single_history_state);
|
||||
s.execute("actions.setHistory", json!({"singleState":false})).unwrap();
|
||||
let mut restored = Session::new();
|
||||
assert!(restored.load_prefs_json(&s.prefs_to_json()).unwrap().is_empty());
|
||||
assert!(!restored.prefs().actions.create_single_history_state);
|
||||
restored.load_prefs_json("{}").unwrap();
|
||||
assert!(restored.prefs().actions.create_single_history_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_document_batch_cannot_change_an_active_history_scope_setting() {
|
||||
for single in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("actions.setHistory", json!({"singleState": single})).unwrap();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let result = s
|
||||
.execute(
|
||||
"file.automate.batch",
|
||||
json!({"source": "open", "action": "Setting boundary", "steps": [
|
||||
["layer.renameLayer", {"name": "Prefix"}], ["actions.setHistory", {"singleState": false}], ["layer.renameLayer", {"name": "Suffix"}]
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
if single {
|
||||
assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}");
|
||||
assert!(result.to_string().contains("cannot change action history settings"), "{result}");
|
||||
assert!(s.prefs().actions.create_single_history_state);
|
||||
assert_eq!(s.active().unwrap().doc.layers[0].name, "Prefix");
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Setting boundary"));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1);
|
||||
} else {
|
||||
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
|
||||
assert!(!s.prefs().actions.create_single_history_state);
|
||||
assert_eq!(s.active().unwrap().doc.layers[0].name, "Suffix");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 2);
|
||||
}
|
||||
s.execute("actions.setHistory", json!({"singleState": !single})).unwrap();
|
||||
assert_eq!(s.prefs().actions.create_single_history_state, !single, "the batch scope ended");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_action_still_refuses_history_setting_changes_with_grouping_on_or_off() {
|
||||
for single in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("actions.setHistory", json!({"singleState": single})).unwrap();
|
||||
s.actions.list = vec![Action::new("Setting", vec![("actions.setHistory".into(), json!({"singleState": !single}))])];
|
||||
let result = s.execute("actions.play", json!({"action": 0})).unwrap();
|
||||
assert_eq!(result["failed"]["id"], "actions.setHistory");
|
||||
assert_eq!(s.prefs().actions.create_single_history_state, single);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accepted_clear_survives_an_action_that_returns_to_empty_captured_history() {
|
||||
for single in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("actions.setHistory", json!({"singleState": single})).unwrap();
|
||||
let original = s.active().unwrap().doc.clone();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), 0);
|
||||
assert!(s.execute("history.clear", json!({})).is_err(), "ordinary empty Clear still refuses");
|
||||
s.actions.list = vec![Action::new(
|
||||
"Empty capture Clear",
|
||||
vec![
|
||||
("edit.fill".into(), json!({"color": "#000000"})),
|
||||
("history.setSource".into(), json!({"state": 1})),
|
||||
("edit.undo".into(), json!({})),
|
||||
("history.clear".into(), json!({})),
|
||||
],
|
||||
)];
|
||||
let result = s.execute("actions.play", json!({"action": 0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
assert!(s.active().unwrap().history.is_cleared());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &original));
|
||||
assert!(s.undo(), "Undo Clear remains available with an empty captured baseline");
|
||||
assert!(!s.active().unwrap().history.is_cleared());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &original));
|
||||
s.execute("edit.fill", json!({"contents": "history"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0), [0.0, 0.0, 0.0, 1.0], "the exact black source is restored");
|
||||
}
|
||||
}
|
||||
|
||||
/// A command's internal purge must not remove the outer action's only Undo snapshot.
|
||||
#[test]
|
||||
fn frame_compound_retains_undo_redo_content_and_layer_targets() {
|
||||
for single in [true, false] {
|
||||
let mut s = session();
|
||||
s.execute("layer.new.layer", json!({"name":"Subject"})).unwrap();
|
||||
s.execute("actions.setHistory", json!({"singleState":single})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let target = s.active().unwrap().active_layer;
|
||||
let selected = s.active().unwrap().selected_layers.clone();
|
||||
let count = s.active().unwrap().history.past_len();
|
||||
s.actions.list = vec![Action::new("Frame", vec![("layer.new.frameFromLayers".into(), json!({}))])];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_ne!(after.as_ref(), before.as_ref());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), count + 1);
|
||||
if single {
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Frame"));
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert_eq!(s.active().unwrap().active_layer, target);
|
||||
assert_eq!(s.active().unwrap().selected_layers, selected);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// A real tilted crop's compound transaction remains inside the outer prefix and suffix.
|
||||
#[test]
|
||||
fn tilted_crop_between_edits_is_one_action() {
|
||||
let mut s = session();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let count = s.active().unwrap().history.past_len();
|
||||
s.actions.list = vec![Action::new(
|
||||
"Crop",
|
||||
vec![
|
||||
("layer.renameLayer".into(), json!({"name":"Prefix"})),
|
||||
("image.crop".into(), json!({"x":0,"y":0,"width":3,"height":3,"angle":10})),
|
||||
("layer.new.layer".into(), json!({"name":"Suffix"})),
|
||||
],
|
||||
)];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), count + 1);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Crop"));
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
|
||||
/// Command-local rollback removes a refused copy but keeps the action's completed rename.
|
||||
#[test]
|
||||
fn again_copy_failure_preserves_the_completed_prefix() {
|
||||
let mut s = session();
|
||||
s.execute("layer.new.layerFromBackground", json!({"ifPresent":true})).unwrap();
|
||||
s.execute("edit.transform", json!({"matrix":[1,0,0,1,1,0]})).unwrap();
|
||||
s.execute("layer.setProps", json!({"locks":{"position":true}})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let count = s.active().unwrap().history.past_len();
|
||||
s.actions.list =
|
||||
vec![Action::new("Prefix", vec![("layer.renameLayer".into(), json!({"name":"Completed prefix"})), ("edit.transform.againCopy".into(), json!({}))])];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["failed"]["step"], 1, "{result}");
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(after.layers.len(), before.layers.len());
|
||||
assert_ne!(after.as_ref(), before.as_ref());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), count + 1);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Prefix"));
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
|
||||
/// A nonempty semantic no-op preserves the original document identity, rows, redo and clean state.
|
||||
#[test]
|
||||
fn same_name_rename_does_not_record_or_clear_redo() {
|
||||
let mut s = session();
|
||||
s.execute("layer.new.layer", json!({"name":"Later"})).unwrap();
|
||||
assert!(s.undo());
|
||||
let st = s.active().unwrap();
|
||||
let before = st.doc.clone();
|
||||
let entries = st.history.entries();
|
||||
let redo: Vec<_> = st.history.redo_labels().map(str::to_string).collect();
|
||||
let ids: Vec<_> = (0..entries.len() + redo.len()).map(|i| st.history.row_id(i)).collect();
|
||||
let dirty = st.is_dirty();
|
||||
let name = st.doc.layer(st.active_layer.unwrap()).unwrap().name.clone();
|
||||
s.actions.list = vec![Action::new("No change", vec![("layer.renameLayer".into(), json!({"name":name}))])];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
let st = s.active().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&st.doc, &before));
|
||||
assert_eq!(st.history.entries(), entries);
|
||||
assert_eq!(st.history.redo_labels().collect::<Vec<_>>(), redo.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
assert_eq!((0..ids.len()).map(|i| st.history.row_id(i)).collect::<Vec<_>>(), ids);
|
||||
assert_eq!(st.is_dirty(), dirty);
|
||||
assert!(s.redo());
|
||||
}
|
||||
|
||||
/// Stop commits its prefix, while Allow Continue includes the following step in that same entry.
|
||||
#[test]
|
||||
fn stop_message_retains_the_completed_prefix() {
|
||||
for allow in [false, true] {
|
||||
let mut s = session();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let count = s.active().unwrap().history.past_len();
|
||||
s.actions.list = vec![Action::new(
|
||||
"Stop",
|
||||
vec![
|
||||
("layer.renameLayer".into(), json!({"name":"Prefix"})),
|
||||
("actions.stopMessage".into(), json!({"message":"Check","allowContinue":allow})),
|
||||
("layer.renameLayer".into(), json!({"name":"Suffix"})),
|
||||
],
|
||||
)];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result.get("failed").is_none(), allow, "{result}");
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, if allow { "Suffix" } else { "Prefix" });
|
||||
assert_eq!(st.history.past_len(), count + 1);
|
||||
assert_eq!(st.history.undo_label(), Some("Action: Stop"));
|
||||
let after = st.doc.clone();
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumed dialog edits and subsequent steps join the published prefix, without an active scope while paused.
|
||||
#[test]
|
||||
fn suspended_history_resumes_the_same_action() {
|
||||
use photocraft_engine::actions_cmds;
|
||||
let mut s = session();
|
||||
let action = s.actions.list[0].clone();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let count = s.active().unwrap().history.past_len();
|
||||
let recording = actions_cmds::begin_playback(&mut s, &action).unwrap();
|
||||
s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap();
|
||||
let continuation = actions_cmds::suspend_history(&mut s).unwrap();
|
||||
actions_cmds::finish_playback(&mut s, recording, &action, 0);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), count + 1);
|
||||
continuation.resume(&mut s).unwrap();
|
||||
s.execute("layer.renameLayer", json!({"name":"Confirmed dialog"})).unwrap();
|
||||
let recording = actions_cmds::begin_playback(&mut s, &action).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name":"Suffix"})).unwrap();
|
||||
actions_cmds::finish_playback(&mut s, recording, &action, 1);
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), count + 1);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Two layers"));
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
assert!(continuation.resume(&mut s).is_err(), "the old continuation cannot join the finalized history");
|
||||
}
|
||||
|
||||
/// An unrelated edit, history purge or document switch during a pause cannot be merged into the action.
|
||||
#[test]
|
||||
fn suspended_history_refuses_document_or_history_drift() {
|
||||
use photocraft_engine::actions_cmds;
|
||||
for drift in 0..3 {
|
||||
let mut s = session();
|
||||
let action = s.actions.list[0].clone();
|
||||
let recording = actions_cmds::begin_playback(&mut s, &action).unwrap();
|
||||
s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap();
|
||||
let continuation = actions_cmds::suspend_history(&mut s).unwrap();
|
||||
actions_cmds::finish_playback(&mut s, recording, &action, 0);
|
||||
match drift {
|
||||
0 => {
|
||||
s.execute("layer.new.layer", json!({"name":"Unrelated"})).unwrap();
|
||||
}
|
||||
1 => {
|
||||
s.active_mut().unwrap().history.purge_last();
|
||||
}
|
||||
_ => {
|
||||
s.execute("file.new", json!({"width":2,"height":2})).unwrap();
|
||||
}
|
||||
}
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let entries = s.active().unwrap().history.entries();
|
||||
assert!(continuation.resume(&mut s).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.entries(), entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// Visibility excluded from history remains excluded when it occurs between grouped edits.
|
||||
#[test]
|
||||
fn grouping_preserves_non_undoable_visibility() {
|
||||
let mut s = session();
|
||||
s.execute("history.options", json!({"makeLayerVisibilityChangesUndoable":false})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let layer = s.active().unwrap().active_layer.unwrap();
|
||||
s.actions.list = vec![Action::new(
|
||||
"Mixed",
|
||||
vec![
|
||||
("layer.renameLayer".into(), json!({"name":"Prefix"})),
|
||||
("layer.setProps".into(), json!({"visible":false})),
|
||||
("layer.renameLayer".into(), json!({"name":"Final"})),
|
||||
],
|
||||
)];
|
||||
let count = s.active().unwrap().history.past_len();
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), count + 1);
|
||||
assert!(s.undo());
|
||||
let actual = s.active().unwrap().doc.layer(layer).unwrap();
|
||||
assert!(!actual.visible);
|
||||
assert_eq!(actual.name, before.layer(layer).unwrap().name);
|
||||
assert!(s.redo());
|
||||
assert!(!s.active().unwrap().doc.layer(layer).unwrap().visible);
|
||||
assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().name, "Final");
|
||||
}
|
||||
|
||||
/// Nonlinear redo states remain ordered, and trimming cannot evict the before-action boundary.
|
||||
#[test]
|
||||
fn grouping_preserves_nonlinear_history_and_a_one_state_limit() {
|
||||
for nonlinear in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("history.options", json!({"allowNonLinearHistory":nonlinear})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name":"Future"})).unwrap();
|
||||
let future = s.active().unwrap().history.row_id(s.active().unwrap().history.past_len()).unwrap();
|
||||
assert!(s.undo());
|
||||
if !nonlinear {
|
||||
s.active_mut().unwrap().history.max_states = 1;
|
||||
}
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
s.actions.list = vec![Action::new("Many", (0..5).map(|i| ("layer.renameLayer".into(), json!({"name":format!("Name{i}")}))).collect())];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Many"));
|
||||
if !nonlinear {
|
||||
assert_eq!(s.active().unwrap().history.past_len(), 1);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
} else {
|
||||
assert!(s.active().unwrap().history.row_of(future).is_some(), "the existing future row keeps its identity");
|
||||
assert!(s.undo());
|
||||
assert!(s.active().unwrap().doc.layers.iter().any(|layer| layer.name == "Future"));
|
||||
}
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check both removed ids and the labels attached to every surviving older row.
|
||||
fn assert_delete_rows(s: &Session, originals: &[(u64, String, std::sync::Arc<photocraft_doc::Document>)], removed: &[usize], case: &str, single_state: bool) {
|
||||
for (i, (id, label, _)) in originals.iter().enumerate() {
|
||||
let row = s.active().unwrap().history.row_of(*id);
|
||||
if removed.contains(&i) {
|
||||
assert!(row.is_none(), "{case}, singleState={single_state}: deleted row {id} returned");
|
||||
} else {
|
||||
let row = row.unwrap_or_else(|| panic!("{case}: surviving row {id} disappeared"));
|
||||
let st = s.active().unwrap();
|
||||
let labels: Vec<_> = st.history.entries().into_iter().chain(st.history.redo_labels().map(str::to_owned)).collect();
|
||||
assert_eq!(&labels[row], label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare Undo with the unchanged oracle for this deletion and history mode.
|
||||
fn assert_delete_boundary(
|
||||
s: &Session,
|
||||
case: &str,
|
||||
single_state: bool,
|
||||
prefix: bool,
|
||||
before: &std::sync::Arc<photocraft_doc::Document>,
|
||||
originals: &[(u64, String, std::sync::Arc<photocraft_doc::Document>)],
|
||||
) {
|
||||
let boundary = if case == "linear-past" {
|
||||
&originals[0].2
|
||||
} else if case == "nonlinear-redo" {
|
||||
// Non-linear history appends the retained future after the original current.
|
||||
&originals[3].2
|
||||
} else if single_state {
|
||||
before
|
||||
} else if case == "nonlinear-past" && prefix {
|
||||
&originals[3].2
|
||||
} else {
|
||||
&originals[4].2
|
||||
};
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), boundary.as_ref(), "{case}, singleState={single_state}, prefix={prefix}");
|
||||
}
|
||||
|
||||
/// Play the real Delete State command, retaining identities of the older rows it did not delete.
|
||||
fn recorded_delete_case(case: &str) {
|
||||
for single_state in [false, true] {
|
||||
for prefix in [false, true] {
|
||||
if !prefix && case != "nonlinear-past" {
|
||||
continue;
|
||||
}
|
||||
let nonlinear = case.starts_with("nonlinear");
|
||||
let mut s = history_control_session(single_state);
|
||||
s.execute("history.options", json!({"allowNonLinearHistory":nonlinear})).unwrap();
|
||||
s.execute("edit.fill", json!({"color":"#0000ff"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color":"#00ffff"})).unwrap();
|
||||
let originals: Vec<_> = (0..=s.active().unwrap().history.past_len())
|
||||
.map(|i| {
|
||||
let st = s.active().unwrap();
|
||||
(st.history.row_id(i).unwrap(), st.history.entries()[i].clone(), st.history.row_doc(i, &st.doc).unwrap())
|
||||
})
|
||||
.collect();
|
||||
let source = if case.ends_with("past") { 1 } else { 4 };
|
||||
s.execute("history.setSource", json!({"state":source})).unwrap();
|
||||
if case == "nonlinear-redo" {
|
||||
assert!(s.undo());
|
||||
assert!(s.undo());
|
||||
}
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let mut steps = Vec::new();
|
||||
if prefix {
|
||||
steps.push(("edit.fill".into(), json!({"color":"#0000ff"})));
|
||||
}
|
||||
if case.ends_with("current") {
|
||||
steps.push(("history.setSource".into(), json!({"state":past + 1})));
|
||||
}
|
||||
if case == "nonlinear-redo" {
|
||||
steps.push(("history.selectState".into(), json!({"state":3})));
|
||||
}
|
||||
let deleted = match case {
|
||||
"linear-past" | "nonlinear-past" => 1,
|
||||
"nonlinear-redo" => 4,
|
||||
_ => past + 1,
|
||||
};
|
||||
steps.extend([
|
||||
("history.deleteState".into(), json!({"state":deleted})),
|
||||
("edit.fill".into(), json!({"color":"#ffff00"})),
|
||||
("edit.fill".into(), json!({"color":"#ff00ff"})),
|
||||
]);
|
||||
s.actions.list = vec![Action::new("Delete state", steps)];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{case}, singleState={single_state}: {result}");
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(after.layers[0].surface().unwrap().rgba(0, 0), [1.0, 0.0, 1.0, 1.0]);
|
||||
let removed: Vec<_> = match case {
|
||||
"linear-past" => (1..5).collect(),
|
||||
"nonlinear-past" => vec![1],
|
||||
"nonlinear-redo" => vec![4],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
assert_delete_rows(&s, &originals, &removed, case, single_state);
|
||||
let count = if single_state { 1 } else { 2 };
|
||||
if single_state {
|
||||
assert_eq!(s.active().unwrap().history.past_len(), originals.len() - removed.len());
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Delete state"));
|
||||
}
|
||||
for _ in 0..count {
|
||||
assert!(s.undo());
|
||||
}
|
||||
assert_delete_boundary(&s, case, single_state, prefix, &before, &originals);
|
||||
for _ in 0..count {
|
||||
assert!(s.redo());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
for i in removed {
|
||||
assert!(s.active().unwrap().history.row_of(originals[i].0).is_none());
|
||||
}
|
||||
assert!(s.execute("edit.fill", json!({"contents":"history"})).is_err(), "the selected deleted source must stay unresolved");
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_linear_past_deletion_keeps_its_boundary_and_suffix() {
|
||||
recorded_delete_case("linear-past");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_linear_current_deletion_keeps_surviving_rows() {
|
||||
recorded_delete_case("linear-current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_nonlinear_past_deletion_keeps_only_surviving_rows() {
|
||||
recorded_delete_case("nonlinear-past");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_nonlinear_current_deletion_keeps_surviving_rows() {
|
||||
recorded_delete_case("nonlinear-current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_nonlinear_redo_deletion_keeps_only_surviving_rows() {
|
||||
recorded_delete_case("nonlinear-redo");
|
||||
}
|
||||
|
||||
/// Purge Undo removes one predecessor, while native snapshots and legitimate older rows remain.
|
||||
fn recorded_user_purge_undo_case(suffix: bool) {
|
||||
for single_state in [false, true] {
|
||||
for prefix in [false, true] {
|
||||
let mut s = history_control_session(single_state);
|
||||
let old: Vec<_> = (0..=s.active().unwrap().history.past_len())
|
||||
.map(|i| {
|
||||
let st = s.active().unwrap();
|
||||
(st.history.row_id(i).unwrap(), st.history.row_doc(i, &st.doc).unwrap())
|
||||
})
|
||||
.collect();
|
||||
let removed = if prefix { 2 } else { 1 };
|
||||
s.execute("history.setSource", json!({"state":removed})).unwrap();
|
||||
let mut steps = Vec::new();
|
||||
if prefix {
|
||||
steps.push(("edit.fill".into(), json!({"color":"#0000ff"})));
|
||||
}
|
||||
steps.extend([("edit.purge.undo".into(), json!({})), ("history.newSnapshot".into(), json!({"name":"Purge boundary"}))]);
|
||||
if suffix {
|
||||
steps.extend([("edit.fill".into(), json!({"color":"#ffff00"})), ("edit.fill".into(), json!({"color":"#ff00ff"}))]);
|
||||
}
|
||||
s.actions.list = vec![Action::new("Purge Undo", steps)];
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "singleState={single_state}, prefix={prefix}, suffix={suffix}: {result}");
|
||||
let st = s.active().unwrap();
|
||||
let after = st.doc.clone();
|
||||
let boundary = st.snapshots.last().unwrap().doc.clone();
|
||||
let snapshot = st.snapshots.last().unwrap().id;
|
||||
assert_eq!(boundary.layers[0].surface().unwrap().rgba(0, 0), if prefix { [0.0, 0.0, 1.0, 1.0] } else { [0.0, 1.0, 0.0, 1.0] });
|
||||
assert!(st.history.row_of(old[removed].0).is_none(), "Purge Undo's removed predecessor returned");
|
||||
assert!(st.history.row_of(old[0].0).is_some());
|
||||
let count = if single_state { 1 } else { 2 };
|
||||
if suffix {
|
||||
for _ in 0..count {
|
||||
assert!(s.undo());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), boundary.as_ref());
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), old[if prefix { 1 } else { 0 }].1.as_ref(), "the purged pixels cannot be undone back into existence");
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), boundary.as_ref());
|
||||
if suffix {
|
||||
for _ in 0..count {
|
||||
assert!(s.redo());
|
||||
}
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
assert!(s.active().unwrap().history.row_of(old[removed].0).is_none());
|
||||
assert!(s.execute("edit.fill", json!({"contents":"history"})).is_err());
|
||||
assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
s.execute("edit.fill", json!({"contents":"history", "snapshot":snapshot})).unwrap();
|
||||
assert_eq!(
|
||||
s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0),
|
||||
boundary.layers[0].surface().unwrap().rgba(0, 0),
|
||||
"native snapshots outlive Purge Undo"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_user_purge_undo_does_not_restore_its_removed_predecessor() {
|
||||
recorded_user_purge_undo_case(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_user_purge_undo_groups_only_the_later_suffix() {
|
||||
recorded_user_purge_undo_case(true);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Synthetic measured-layout recordings through public ATN load/save and playback.
|
||||
use photocraft_engine::{Session, actions_cmds::Action, atn_cmds};
|
||||
use photocraft_psd::{
|
||||
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
|
||||
descriptor::{Descriptor, Id, UnicodeString, Value as D},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn enm(ty: &str, value: &str) -> D {
|
||||
D::Enumerated { type_id: Id::new(ty), value: Id::new(value) }
|
||||
}
|
||||
fn pct(value: f64) -> D {
|
||||
D::UnitFloat { unit: *b"#Prc", value }
|
||||
}
|
||||
fn point(x: f64, y: f64) -> D {
|
||||
D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: x }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: y }))
|
||||
}
|
||||
fn color(t: i32, rgb: [f64; 3]) -> D {
|
||||
let c = Descriptor::new("RGBC").with("Rd ", D::Double(rgb[0])).with("Grn ", D::Double(rgb[1])).with("Bl ", D::Double(rgb[2]));
|
||||
D::Descriptor(
|
||||
Descriptor::new("Clrt").with("Clr ", D::Descriptor(c)).with("Type", enm("Clry", "UsrS")).with("Lctn", D::Integer(t)).with("Mdpn", D::Integer(50)),
|
||||
)
|
||||
}
|
||||
fn opacity(t: i32, value: f64) -> D {
|
||||
D::Descriptor(Descriptor::new("TrnS").with("Opct", pct(value)).with("Lctn", D::Integer(t)).with("Mdpn", D::Integer(50)))
|
||||
}
|
||||
fn fixture(style: &str, method: &str, changed: bool) -> Descriptor {
|
||||
let gradient = Descriptor::new("Grdn")
|
||||
.with("Nm ", D::Text(UnicodeString::new_nul("Recorded gradient")))
|
||||
.with("GrdF", enm("GrdF", "CstS"))
|
||||
.with("Intr", D::Double(4096.0))
|
||||
.with("Clrs", D::List(vec![color(0, [0.0, 32.0, 255.0]), color(4096, [255.0, 224.0, 0.0])]))
|
||||
.with("Trns", D::List(vec![opacity(0, 20.0), opacity(4096, 80.0)]));
|
||||
let mut d = Descriptor::new("null").with("From", point(1.0, 1.0)).with("T ", point(6.0, 2.0));
|
||||
if changed {
|
||||
d = d.with("Opct", pct(54.5)).with("Md ", enm("BlnM", "Mltp"));
|
||||
}
|
||||
d = d.with("Type", enm("GrdT", style));
|
||||
if changed {
|
||||
d = d.with("Rvrs", D::Boolean(true)).with("UsMs", D::Boolean(true));
|
||||
}
|
||||
d.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", method)).with("Grad", D::Descriptor(gradient))
|
||||
}
|
||||
fn recording(event: AtnEvent, d: Descriptor) -> Vec<u8> {
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Set"),
|
||||
actions: vec![AtnAction {
|
||||
name: UnicodeString::new_nul("Gradient"),
|
||||
items: vec![AtnItem { event, descriptor: Some(d), dictionary: b"Gradient".to_vec(), ..Default::default() }],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
atn::write(&file).unwrap()
|
||||
}
|
||||
fn imported(d: Descriptor) -> atn_cmds::Imported {
|
||||
atn_cmds::import(&recording(AtnEvent::Code(*b"Grdn"), d)).unwrap()
|
||||
}
|
||||
fn saved(action: &Action) -> AtnItem {
|
||||
let (bytes, skipped) = atn_cmds::export("Set", &[action], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
atn::parse(&bytes).unwrap().actions[0].items[0].clone()
|
||||
}
|
||||
fn replace(d: &mut Descriptor, key: &str, v: D) {
|
||||
d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 = v;
|
||||
}
|
||||
fn obj<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut Descriptor {
|
||||
let D::Descriptor(value) = &mut d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 else { panic!("not an object") };
|
||||
value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_event_ids_and_top_level_classes_keep_complete_unchanged_descriptors() {
|
||||
for class in ["null", "Grdn"] {
|
||||
for style in ["Lnr ", "Rdl ", "Angl", "Rflc", "Dmnd"] {
|
||||
for method in ["Gcls", "Perc", "Lnr ", "Smoo"] {
|
||||
for changed in [false, true] {
|
||||
for event in [AtnEvent::Code(*b"Grdn"), AtnEvent::name("gradientClassEvent")] {
|
||||
let mut d = fixture(style, method, changed);
|
||||
d.class_id = Id::new(class);
|
||||
let loaded = atn_cmds::import(&recording(event, d.clone())).unwrap();
|
||||
assert!(loaded.unsupported.is_empty(), "{style} {method}");
|
||||
let action = &loaded.actions[0];
|
||||
assert_eq!(action.steps[0].0, "paint.gradient");
|
||||
assert_eq!(saved(action).descriptor.unwrap().to_bytes(), d.to_bytes());
|
||||
assert_eq!(action.steps[0].1["dither"], false, "omitted Dthr is off, unlike native default");
|
||||
assert_eq!(action.steps[0].1["opacity"], if changed { json!(54.5) } else { json!(100) });
|
||||
assert_eq!(action.steps[0].1["transparency"], if changed { json!([[0, 20], [1, 80]]) } else { json!([]) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_defaults_key_order_id_encoding_and_flags_are_preserved() {
|
||||
let mut d = fixture("Lnr ", "Gcls", false);
|
||||
d.items.push((Id::Str(b"Opct".to_vec()), pct(100.0)));
|
||||
d.items.push((Id::new("Dthr"), D::Boolean(false)));
|
||||
d.items.push((Id::new("Rvrs"), D::Boolean(false)));
|
||||
d.items.reverse();
|
||||
let mut loaded = imported(d.clone());
|
||||
assert!(loaded.unsupported.is_empty());
|
||||
loaded.actions[0].disabled.insert(0);
|
||||
loaded.actions[0].dialogs.insert(0);
|
||||
let item = saved(&loaded.actions[0]);
|
||||
assert_eq!(item.descriptor.unwrap().to_bytes(), d.to_bytes());
|
||||
assert!(!item.enabled);
|
||||
assert!(item.with_dialog);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_edits_replace_stale_source_and_export_recorded_default_omissions() {
|
||||
let original = fixture("Rdl ", "Perc", true);
|
||||
let mut loaded = imported(original.clone());
|
||||
let p = &mut loaded.actions[0].steps[0].1;
|
||||
p["opacity"] = json!(100);
|
||||
p["mode"] = json!("Normal");
|
||||
p["reverse"] = json!(false);
|
||||
p["dither"] = json!(true);
|
||||
p["transparency"] = json!([]);
|
||||
p["from"] = json!([2, 3]);
|
||||
let item = saved(&loaded.actions[0]);
|
||||
let d = item.descriptor.unwrap();
|
||||
assert_ne!(d.to_bytes(), original.to_bytes());
|
||||
let keys: Vec<&[u8]> = d.items.iter().map(|(k, _)| k.as_bytes()).collect();
|
||||
assert_eq!(keys, vec![b"From".as_slice(), b"T ", b"Type", b"Dthr", b"gradientsInterpolationMethod", b"Grad"]);
|
||||
let back = imported(d);
|
||||
assert!(back.unsupported.is_empty());
|
||||
assert_eq!(back.actions[0].steps[0].1["from"], json!([2, 3]));
|
||||
assert_eq!(back.actions[0].steps[0].1["dither"], true);
|
||||
assert_eq!(back.actions[0].steps[0].1["transparency"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_unknown_and_unrepresentable_descriptors_stay_whole() {
|
||||
let base = fixture("Lnr ", "Gcls", true);
|
||||
let mut bad = Vec::new();
|
||||
let mut d = base.clone();
|
||||
d.class_id = Id::new("UnknownGradient");
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
d.items.retain(|(k, _)| !k.is("Grad"));
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
d.items.retain(|(k, _)| !k.is("gradientsInterpolationMethod"));
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
replace(&mut d, "Type", enm("GrdT", "Unknown"));
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
replace(&mut d, "Type", enm("wrong", "Lnr "));
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
replace(&mut d, "Opct", D::Double(54.5));
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
d.items.push((Id::new("Dthr"), D::Integer(1)));
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
d.items.push((Id::Str(b"Opct".to_vec()), pct(100.0)));
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
d.items.push((Id::new("unknownSetting"), D::Boolean(true)));
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
replace(&mut d, "gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "unknown"));
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
replace(obj(&mut d, "From"), "Hrzn", D::UnitFloat { unit: *b"#Pxl", value: f64::NAN });
|
||||
bad.push(d);
|
||||
let mut d = base.clone();
|
||||
replace(obj(&mut d, "From"), "Hrzn", pct(1.0));
|
||||
bad.push(d);
|
||||
for (key, value) in [("Mdpn", D::Integer(25)), ("Type", enm("Clry", "FrgC")), ("Lctn", D::Integer(-1))] {
|
||||
let mut d = base.clone();
|
||||
let D::List(stops) = &mut obj(&mut d, "Grad").items.iter_mut().find(|(k, _)| k.is("Clrs")).unwrap().1 else { panic!() };
|
||||
let D::Descriptor(stop) = &mut stops[0] else { panic!() };
|
||||
replace(stop, key, value);
|
||||
bad.push(d);
|
||||
}
|
||||
let mut d = base.clone();
|
||||
let D::List(stops) = &mut obj(&mut d, "Grad").items.iter_mut().find(|(k, _)| k.is("Clrs")).unwrap().1 else { panic!() };
|
||||
let D::Descriptor(stop) = &mut stops[0] else { panic!() };
|
||||
replace(obj(stop, "Clr "), "Rd ", D::Double(0.5));
|
||||
bad.push(d);
|
||||
for d in bad {
|
||||
let bytes = d.to_bytes();
|
||||
let loaded = imported(d);
|
||||
assert_eq!(loaded.unsupported.len(), 1);
|
||||
assert_eq!(loaded.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP);
|
||||
assert_eq!(saved(&loaded.actions[0]).descriptor.unwrap().to_bytes(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_native_targets_and_damaged_origins_are_refused() {
|
||||
let loaded = imported(fixture("Lnr ", "Gcls", false));
|
||||
for (key, value) in [
|
||||
("target", json!("mask")),
|
||||
("gradient", json!("current preset")),
|
||||
("method", json!("unknown")),
|
||||
("style", json!("unknown")),
|
||||
("style", json!(42)),
|
||||
("__atnClassicGradient", json!("not base64")),
|
||||
("__atnClassicGradient", json!("A".repeat(128 * 1024 + 1))),
|
||||
("stops", json!([[0, "foreground"], [1, "background"]])),
|
||||
("stops", json!([[0, "#000000"]])),
|
||||
("stops", json!([[0.1, "#000000"], [1, "#ffffff"]])),
|
||||
] {
|
||||
let mut action = loaded.actions[0].clone();
|
||||
action.steps[0].1[key] = value;
|
||||
assert!(atn_cmds::export("S", &[&action], &[], false).is_err(), "{key}");
|
||||
let (_, skipped) = atn_cmds::export("S", &[&action], &[], true).unwrap();
|
||||
assert_eq!(skipped.len(), 1, "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_and_transparency_stop_counts_have_supported_and_refused_boundaries() {
|
||||
for key in ["Clrs", "Trns"] {
|
||||
for count in [1, 2, 64, 65] {
|
||||
let mut d = fixture("Lnr ", "Gcls", true);
|
||||
let stops = (0..count)
|
||||
.map(|i| {
|
||||
let t = i * 4096 / (count - 1).max(1);
|
||||
if key == "Clrs" { color(t, [32.0, 96.0, 224.0]) } else { opacity(t, 50.0) }
|
||||
})
|
||||
.collect();
|
||||
replace(obj(&mut d, "Grad"), key, D::List(stops));
|
||||
let bytes = d.to_bytes();
|
||||
let loaded = imported(d);
|
||||
assert_eq!(loaded.unsupported.is_empty(), (2..=64).contains(&count), "{key} {count}");
|
||||
assert_eq!(saved(&loaded.actions[0]).descriptor.unwrap().to_bytes(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_transparency_and_dither_use_recorded_defaults_during_playback() {
|
||||
for depth in [8, 16, 32] {
|
||||
for dither in [false, true] {
|
||||
let mut d = fixture("Lnr ", "Gcls", false);
|
||||
d.class_id = Id::new("Grdn");
|
||||
if dither {
|
||||
d.items.push((Id::new("Dthr"), D::Boolean(true)));
|
||||
}
|
||||
let loaded = imported(d);
|
||||
assert!(loaded.unsupported.is_empty());
|
||||
let mut actual = Session::new();
|
||||
let mut control = Session::new();
|
||||
for s in [&mut actual, &mut control] {
|
||||
s.execute("file.new", json!({"width":8,"height":4,"depth":depth,"background":"#204060"})).unwrap();
|
||||
}
|
||||
actual.actions.list = loaded.actions;
|
||||
actual.execute("actions.play", json!({"action":0})).unwrap();
|
||||
// The descriptor contains non-opaque Trns, but omitted UsMs disables them.
|
||||
// Dthr=false is explicit, overriding the native command's true default.
|
||||
control
|
||||
.execute(
|
||||
"paint.gradient",
|
||||
json!({"from":[1,1],"to":[6,2],"style":"linear","method":"classic","smoothness":100,
|
||||
"stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[],"opacity":100,"mode":"Normal","reverse":false,"dither":dither}),
|
||||
)
|
||||
.unwrap();
|
||||
let a = actual.active().unwrap().doc.layers[0].surface().unwrap();
|
||||
let b = control.active().unwrap().doc.layers[0].surface().unwrap();
|
||||
for y in 0..4 {
|
||||
for x in 0..8 {
|
||||
assert_eq!(a.pixel(x, y), b.pixel(x, y), "{depth} dither={dither}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_gradients_match_native_pixels_and_undo_at_each_depth() {
|
||||
for class in ["null", "Grdn"] {
|
||||
for depth in [8, 16, 32] {
|
||||
for (style, native_style) in [("Lnr ", "linear"), ("Rdl ", "radial"), ("Angl", "angle"), ("Rflc", "reflected"), ("Dmnd", "diamond")] {
|
||||
for (method, native_method) in [("Gcls", "classic"), ("Perc", "perceptual"), ("Lnr ", "linear"), ("Smoo", "smooth")] {
|
||||
let mut descriptor = fixture(style, method, true);
|
||||
descriptor.class_id = Id::new(class);
|
||||
let loaded = imported(descriptor);
|
||||
assert!(loaded.unsupported.is_empty());
|
||||
let mut actual = Session::new();
|
||||
let mut control = Session::new();
|
||||
for s in [&mut actual, &mut control] {
|
||||
s.execute("file.new", json!({"width":8,"height":4,"depth":depth,"background":"#204060"})).unwrap();
|
||||
}
|
||||
let before = actual.active().unwrap().doc.clone();
|
||||
actual.actions.list = loaded.actions;
|
||||
actual.execute("actions.play", json!({"action":0})).unwrap();
|
||||
control
|
||||
.execute(
|
||||
"paint.gradient",
|
||||
json!({"from":[1,1],"to":[6,2],"style":native_style,"method":native_method,"smoothness":100,
|
||||
"stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[[0,20],[1,80]],"opacity":54.5,"mode":"Multiply","reverse":true,"dither":false}),
|
||||
)
|
||||
.unwrap();
|
||||
let pixels = |s: &Session| s.active().unwrap().doc.layers[0].surface().unwrap().clone();
|
||||
let (a, b) = (pixels(&actual), pixels(&control));
|
||||
for y in 0..4 {
|
||||
for x in 0..8 {
|
||||
assert_eq!(a.pixel(x, y), b.pixel(x, y), "{depth} {style} {method} ({x},{y})");
|
||||
}
|
||||
}
|
||||
assert_ne!(a.pixel(3, 1), before.layers[0].surface().unwrap().pixel(3, 1), "non-vacuous paint");
|
||||
assert!(actual.undo());
|
||||
assert_eq!(actual.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! Recorded CAF settings use the native operation; unsupported descriptors stay whole.
|
||||
use photocraft_engine::{
|
||||
Session,
|
||||
actions_cmds::Action,
|
||||
atn_cmds::{export, import},
|
||||
};
|
||||
use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem};
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const CMD: &str = "edit.contentAwareFill";
|
||||
|
||||
fn enm(key: &str, value: &str) -> D {
|
||||
D::Enumerated { type_id: Id::new(key), value: Id::new(value) }
|
||||
}
|
||||
|
||||
fn descriptor() -> Descriptor {
|
||||
Descriptor::new("null")
|
||||
.with("cafSamplingRegion", enm("cafSamplingRegion", "cafSamplingRegionRectangular"))
|
||||
.with("cafSampleAllLayers", D::Boolean(false))
|
||||
.with("cafColorAdaptationLevel", enm("cafColorAdaptationLevel", "cafColorAdaptationDefault"))
|
||||
.with("cafRotationAmount", enm("cafRotationAmount", "cafRotationAmountNone"))
|
||||
.with("cafScale", D::Boolean(false))
|
||||
.with("cafMirror", D::Boolean(false))
|
||||
.with("cafOutput", enm("cafOutput", "cafOutputToNewLayer"))
|
||||
}
|
||||
|
||||
fn params() -> Value {
|
||||
json!({"sampling":"rectangular","sampleAllLayers":false,"colorAdaptation":"default",
|
||||
"rotationAdaptation":"none","scale":false,"mirror":false,"output":"new"})
|
||||
}
|
||||
|
||||
fn item(d: Descriptor) -> AtnItem {
|
||||
AtnItem { event: AtnEvent::name("cafWorkspace"), dictionary: b"Content-Aware Fill".to_vec(), descriptor: Some(d), ..Default::default() }
|
||||
}
|
||||
|
||||
fn file(item: AtnItem) -> AtnFile {
|
||||
AtnFile {
|
||||
name: UnicodeString::new_nul("S"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item], ..Default::default() }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn load(d: Descriptor) -> Action {
|
||||
let loaded = import(&atn::write(&file(item(d))).unwrap()).unwrap();
|
||||
assert!(loaded.unsupported.is_empty());
|
||||
loaded.actions.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
fn exported(p: Value) -> Option<AtnItem> {
|
||||
let a = Action::new("A", vec![(CMD.into(), p)]);
|
||||
let bytes = export("S", &[&a], &[], false).ok()?.0;
|
||||
atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next()
|
||||
}
|
||||
|
||||
/// Each field retains its native meaning, recorded type and position, including false booleans.
|
||||
#[test]
|
||||
fn caf_settings_roundtrip_seven_fields_and_boolean_choices() {
|
||||
let base = descriptor();
|
||||
let mut cases = Vec::new();
|
||||
for bits in 0..8 {
|
||||
let mut d = base.clone();
|
||||
let mut p = params();
|
||||
for (bit, index, param) in [(0, 1, "sampleAllLayers"), (1, 4, "scale"), (2, 5, "mirror")] {
|
||||
let on = bits & (1 << bit) != 0;
|
||||
d.items[index].1 = D::Boolean(on);
|
||||
p[param] = json!(on);
|
||||
}
|
||||
cases.push((d, p));
|
||||
}
|
||||
let mut current = base.clone();
|
||||
current.items[6].1 = enm("cafOutput", "cafOutputToCurrentLayer");
|
||||
let mut p = params();
|
||||
p["output"] = json!("current");
|
||||
cases.push((current, p));
|
||||
for (d, p) in cases {
|
||||
let action = load(d.clone());
|
||||
assert_eq!(action.steps, vec![(CMD.into(), p.clone())]);
|
||||
assert_eq!(exported(p).unwrap(), item(d.clone()));
|
||||
let bytes = export("S", &[&action], &[], false).unwrap().0;
|
||||
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], item(d));
|
||||
}
|
||||
}
|
||||
|
||||
/// Unedited source identity, order and flags survive; damaged preservation metadata is refused.
|
||||
#[test]
|
||||
fn caf_source_order_and_flags_survive_without_reviving_stale_settings() {
|
||||
let mut d = descriptor();
|
||||
d.name = UnicodeString::new_nul("Recorded CAF");
|
||||
d.class_id = Id::Str(b"null".to_vec());
|
||||
d.items.reverse();
|
||||
let mut source = item(d);
|
||||
source.enabled = false;
|
||||
source.with_dialog = true;
|
||||
source.dialog_options = 2;
|
||||
let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap();
|
||||
assert!(imported.unsupported.is_empty());
|
||||
let a = &imported.actions[0];
|
||||
assert!(a.steps[0].1.get("_atnCafDescriptor").is_some());
|
||||
let bytes = export("S", &[a], &[], false).unwrap().0;
|
||||
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], source);
|
||||
let mut damaged = a.steps[0].1.clone();
|
||||
damaged["_atnCafDescriptor"] = json!("not base64");
|
||||
assert!(exported(damaged).is_none());
|
||||
let invalid = descriptor().with("extra", D::Boolean(true));
|
||||
let mut damaged = a.steps[0].1.clone();
|
||||
damaged["_atnCafDescriptor"] = json!(photocraft_paint::tile::b64_encode(&invalid.to_bytes()));
|
||||
assert!(exported(damaged).is_none(), "valid base64 cannot hide an unrepresented source field");
|
||||
}
|
||||
|
||||
/// Representable edits export their new values canonically, retaining step flags without skips.
|
||||
#[test]
|
||||
fn caf_edited_settings_export_without_stale_wire_metadata() {
|
||||
let mut d = descriptor();
|
||||
d.name = UnicodeString::new_nul("Recorded CAF");
|
||||
d.class_id = Id::Str(b"null".to_vec());
|
||||
d.items.reverse();
|
||||
let mut source = item(d);
|
||||
source.enabled = false;
|
||||
source.with_dialog = true;
|
||||
source.dialog_options = 2;
|
||||
let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap();
|
||||
assert!(imported.unsupported.is_empty());
|
||||
let a = &imported.actions[0];
|
||||
assert!(a.steps[0].1.get("_atnCafDescriptor").is_some());
|
||||
for (param, index, value, wire) in [
|
||||
("sampleAllLayers", 1, json!(true), D::Boolean(true)),
|
||||
("scale", 4, json!(true), D::Boolean(true)),
|
||||
("mirror", 5, json!(true), D::Boolean(true)),
|
||||
("output", 6, json!("current"), enm("cafOutput", "cafOutputToCurrentLayer")),
|
||||
] {
|
||||
let mut edited = a.clone();
|
||||
edited.steps[0].1[param] = value.clone();
|
||||
let mut expected_params = params();
|
||||
expected_params[param] = value;
|
||||
let mut canonical = descriptor();
|
||||
canonical.items[index].1 = wire;
|
||||
let mut expected = source.clone();
|
||||
expected.descriptor = Some(canonical);
|
||||
for skip in [false, true] {
|
||||
let (bytes, skipped) = export("S", &[&edited], &[], skip).unwrap();
|
||||
assert!(skipped.is_empty(), "a supported {param} edit must not be skipped");
|
||||
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items, vec![expected.clone()]);
|
||||
let reloaded = import(&bytes).unwrap();
|
||||
assert!(reloaded.unsupported.is_empty());
|
||||
assert_eq!(reloaded.actions[0].steps[0].1, expected_params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing defaults, malformed/repeated fields and unrepresented workspace state stay complete.
|
||||
#[test]
|
||||
fn caf_unrepresented_descriptors_are_kept_and_never_partially_play() {
|
||||
let base = descriptor();
|
||||
let mut cases = vec![Descriptor::new("null"), base.clone().with("extra", D::Boolean(true))];
|
||||
for i in 0..base.items.len() {
|
||||
let mut missing = base.clone();
|
||||
missing.items.remove(i);
|
||||
cases.push(missing);
|
||||
let mut repeated = base.clone();
|
||||
repeated.items.push(repeated.items[i].clone());
|
||||
cases.push(repeated);
|
||||
let mut wrong = base.clone();
|
||||
wrong.items[i].1 = D::Integer(0);
|
||||
cases.push(wrong);
|
||||
}
|
||||
for (i, key, value) in [
|
||||
(0, "cafSamplingRegion", "cafSamplingRegionAuto"),
|
||||
(0, "cafSamplingRegion", "cafSamplingRegionCustom"),
|
||||
(2, "cafColorAdaptationLevel", "cafColorAdaptationNone"),
|
||||
(2, "cafColorAdaptationLevel", "cafColorAdaptationHigh"),
|
||||
(2, "cafColorAdaptationLevel", "cafColorAdaptationVeryHigh"),
|
||||
(2, "cafColorAdaptationLevel", "unknown"),
|
||||
(3, "cafRotationAmount", "cafRotationAmountLow"),
|
||||
(3, "cafRotationAmount", "cafRotationAmountMedium"),
|
||||
(3, "cafRotationAmount", "cafRotationAmountHigh"),
|
||||
(3, "cafRotationAmount", "cafRotationAmountFull"),
|
||||
(3, "cafRotationAmount", "unknown"),
|
||||
(6, "cafOutput", "cafOutputToDuplicateLayer"),
|
||||
] {
|
||||
let mut d = base.clone();
|
||||
d.items[i].1 = enm(key, value);
|
||||
cases.push(d);
|
||||
}
|
||||
let mut wrong_class = base.clone();
|
||||
wrong_class.items[0].1 = enm("unrelatedEnum", "cafSamplingRegionRectangular");
|
||||
cases.push(wrong_class);
|
||||
for d in cases {
|
||||
let source = item(d);
|
||||
let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap();
|
||||
assert_eq!(imported.unsupported.len(), 1);
|
||||
assert_eq!(imported.actions[0].steps[0].0, "actions.photoshopStep");
|
||||
let saved = export("S", &[&imported.actions[0]], &[], false).unwrap().0;
|
||||
assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0], source);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":8,"height":8})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.actions.list.extend(imported.actions);
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 0);
|
||||
assert!(result.get("failed").is_some());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
}
|
||||
}
|
||||
|
||||
/// Known native defaults become explicit values; unsupported defaults/options cannot disappear.
|
||||
#[test]
|
||||
fn caf_native_exports_preserve_effective_defaults_and_refuse_unrepresented_options() {
|
||||
for p in [json!({"sampling":"rectangular","output":"new"}), json!({"sampling":"rectangular","output":"new","seed":1,"target":"pixels"}), params()] {
|
||||
let a = load(exported(p).unwrap().descriptor.unwrap());
|
||||
assert_eq!(a.steps, vec![(CMD.into(), params())]);
|
||||
}
|
||||
let mut current = params();
|
||||
current["output"] = json!("current");
|
||||
for p in [json!({"sampling":"rectangular"}), current.clone()] {
|
||||
let a = load(exported(p).unwrap().descriptor.unwrap());
|
||||
assert_eq!(a.steps, vec![(CMD.into(), current.clone())]);
|
||||
}
|
||||
for (key, value) in [
|
||||
("sampling", json!("auto")),
|
||||
("sampling", json!("custom")),
|
||||
("sampling", json!("unknown")),
|
||||
("output", json!("duplicate")),
|
||||
("colorAdaptation", json!("none")),
|
||||
("colorAdaptation", json!("high")),
|
||||
("colorAdaptation", json!("veryHigh")),
|
||||
("rotationAdaptation", json!("low")),
|
||||
("rotationAdaptation", json!("medium")),
|
||||
("rotationAdaptation", json!("high")),
|
||||
("rotationAdaptation", json!("full")),
|
||||
("sampleAllLayers", json!(0)),
|
||||
("scale", Value::Null),
|
||||
("mirror", json!("true")),
|
||||
("seed", json!(2)),
|
||||
("layer", json!(1)),
|
||||
("margin", json!(10)),
|
||||
("area", json!([0, 0, 4, 4])),
|
||||
("channel", json!(0)),
|
||||
("samplingStrokes", json!([])),
|
||||
("fillEdits", json!([])),
|
||||
("target", json!("mask")),
|
||||
("_atnCafDescriptor", Value::Null),
|
||||
] {
|
||||
let mut p = params();
|
||||
p[key] = value;
|
||||
assert!(exported(p.clone()).is_none(), "{p}");
|
||||
}
|
||||
for p in [json!({}), json!({"output":"new"}), json!([])] {
|
||||
assert!(exported(p.clone()).is_none(), "unrepresented native default: {p}");
|
||||
}
|
||||
}
|
||||
|
||||
fn session(depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"white"})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground":"#ff0000"})).unwrap();
|
||||
s.execute("select.rect", json!({"x":3,"y":3,"width":2,"height":2})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents":"foreground"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Compare the complete small fixture, including pixels outside the selected fill area.
|
||||
fn assert_same_pixels(actual: &Surface, expected: &Surface, depth: u32, output: &str) {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
assert_eq!(actual.rgba(x, y), expected.rgba(x, y), "{depth}/{output}: pixel{x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native and imported fills produce the same real pixels, source/selection state and Undo/Redo.
|
||||
#[test]
|
||||
fn caf_action_matches_native_pixels_and_undo_at_each_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session(depth);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let source = s.active().unwrap().active_layer.unwrap();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.actions.list.push(load(descriptor()));
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{depth}: {result}");
|
||||
assert_eq!(result["ran"], 1);
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert_eq!(after.layers.len(), before.layers.len() + 1);
|
||||
assert_eq!(after.selection, before.selection);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1);
|
||||
assert_eq!(after.layer(source), before.layer(source), "new output keeps its source unchanged");
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let pixels = after.layer(id).unwrap().surface().unwrap();
|
||||
let center = pixels.rgba(3, 3);
|
||||
assert!(center.iter().all(|v| (*v - 1.0).abs() < 0.01), "{depth}: hole was not filled: {center:?}");
|
||||
assert_eq!(pixels.rgba(0, 0)[3], 0.0, "new output is transparent outside the selection");
|
||||
let mut native = session(depth);
|
||||
native.execute(CMD, params()).unwrap();
|
||||
let expected = &native.active().unwrap().doc;
|
||||
assert_eq!(after.layers.len(), expected.layers.len());
|
||||
for (a, b) in after.layers.iter().zip(&expected.layers) {
|
||||
let (a, b) = (a.surface().unwrap(), b.surface().unwrap());
|
||||
assert_same_pixels(a, b, depth, "new");
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc, after);
|
||||
|
||||
// Current Layer keeps one layer and matches the ordinary native operation as well.
|
||||
let mut current = session(depth);
|
||||
let mut p = params();
|
||||
p["output"] = json!("current");
|
||||
current.execute(CMD, p).unwrap();
|
||||
assert_eq!(current.active().unwrap().doc.layers.len(), 1);
|
||||
let center = current.active().unwrap().doc.layers[0].surface().unwrap().rgba(3, 3);
|
||||
assert!(center.iter().all(|v| (*v - 1.0).abs() < 0.01), "{depth}: native Current Layer control failed: {center:?}");
|
||||
let mut recorded = session(depth);
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let source = recorded.active().unwrap().active_layer.unwrap();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
let mut d = descriptor();
|
||||
d.items[6].1 = enm("cafOutput", "cafOutputToCurrentLayer");
|
||||
recorded.actions.list.push(load(d));
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none(), "{depth}: {result}");
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
assert_eq!(after.layers.len(), before.layers.len());
|
||||
assert_eq!(after.selection, before.selection);
|
||||
assert_eq!(recorded.active().unwrap().active_layer, Some(source));
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + 1);
|
||||
let expected = current.active().unwrap().doc.layers[0].surface().unwrap();
|
||||
let actual = after.layer(source).unwrap().surface().unwrap();
|
||||
assert_same_pixels(actual, expected, depth, "current");
|
||||
assert!(recorded.undo());
|
||||
assert_eq!(recorded.active().unwrap().doc, before);
|
||||
assert!(recorded.redo());
|
||||
assert_eq!(recorded.active().unwrap().doc, after);
|
||||
}
|
||||
}
|
||||
|
||||
/// A translucent active layer over white makes layer-only and composite samples differ.
|
||||
fn multilayer_session(depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name":"Translucent sample"})).unwrap();
|
||||
let layer = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("Translucent sample pixels", |doc, _| {
|
||||
let bounds = doc.bounds();
|
||||
doc.layer_mut(layer).unwrap().surface_mut().unwrap().write_region(bounds, &[0.0, 0.0, 1.0, 0.5].repeat(64));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.rect", json!({"x":3,"y":3,"width":2,"height":2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Current Layer preserves both sampling choices; distinct native controls detect a forced false.
|
||||
#[test]
|
||||
fn caf_current_layer_sampling_matches_distinct_multilayer_controls() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut native_centers = Vec::new();
|
||||
for all_layers in [false, true] {
|
||||
let mut native = multilayer_session(depth);
|
||||
let mut p = params();
|
||||
p["output"] = json!("current");
|
||||
p["sampleAllLayers"] = json!(all_layers);
|
||||
native.execute(CMD, p).unwrap();
|
||||
let native_id = native.active().unwrap().active_layer.unwrap();
|
||||
let expected = native.active().unwrap().doc.layer(native_id).unwrap().surface().unwrap();
|
||||
native_centers.push(expected.rgba(3, 3));
|
||||
|
||||
let mut recorded = multilayer_session(depth);
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let source = recorded.active().unwrap().active_layer.unwrap();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
let mut d = descriptor();
|
||||
d.items[1].1 = D::Boolean(all_layers);
|
||||
d.items[6].1 = enm("cafOutput", "cafOutputToCurrentLayer");
|
||||
recorded.actions.list.push(load(d));
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none(), "{depth}/{all_layers}: {result}");
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
assert_eq!(after.layers.len(), 2);
|
||||
assert_eq!(after.selection, before.selection);
|
||||
assert_eq!(recorded.active().unwrap().active_layer, Some(source));
|
||||
// One `Action:` entry when the play changed the document, none when it didn't: a fill
|
||||
// from the uniform layer alone can rewrite it with the same pixels (#10944).
|
||||
let changed = usize::from(after != before);
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + changed);
|
||||
let actual = after.layer(source).unwrap().surface().unwrap();
|
||||
assert_same_pixels(actual, expected, depth, "current multilayer");
|
||||
assert_eq!(actual.rgba(0, 0), before.layer(source).unwrap().surface().unwrap().rgba(0, 0));
|
||||
for layer in before.layers.iter().filter(|layer| layer.id != source) {
|
||||
assert_eq!(after.layer(layer.id), Some(layer));
|
||||
}
|
||||
if changed == 1 {
|
||||
assert!(recorded.undo());
|
||||
assert_eq!(recorded.active().unwrap().doc, before);
|
||||
assert!(recorded.redo());
|
||||
assert_eq!(recorded.active().unwrap().doc, after);
|
||||
}
|
||||
}
|
||||
assert_ne!(native_centers[0], native_centers[1], "{depth}: fixture must distinguish layer-only from composite sampling");
|
||||
}
|
||||
}
|
||||
|
||||
/// The adapter cannot manufacture a selection or turn a refused native operation into an edit.
|
||||
#[test]
|
||||
fn caf_action_without_selection_fails_without_an_edit() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":8,"height":8})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.actions.list.push(load(descriptor()));
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 0);
|
||||
assert!(result.get("failed").is_some());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ fn equivalent(actual: &Session, expected: &Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo exactly the native control's history edits, then redo the complete document state.
|
||||
/// Undo the expected playback history edits, then redo the complete document state.
|
||||
fn undo_redo(s: &mut Session, before: &Document, after: &Document, edits: usize) {
|
||||
for _ in 0..edits {
|
||||
assert!(s.undo());
|
||||
@@ -164,45 +164,62 @@ fn create_layers_playback_matches_native_effect_layers_and_undo_at_each_depth()
|
||||
/// The recorded whole-percent limits and interior values match native effect scaling at all depths.
|
||||
#[test]
|
||||
fn scale_effects_playback_matches_native_settings_and_undo_at_each_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
for percent in [1.0, 100.0, 200.0, 1000.0] {
|
||||
let (mut native, native_source) = fixture(depth);
|
||||
let native_past = native.active().unwrap().history.past_len();
|
||||
native.execute(SCALE, json!({"scale":percent})).unwrap();
|
||||
scaled_shadow(&native, native_source, percent);
|
||||
let edits = native.active().unwrap().history.past_len() - native_past;
|
||||
let (mut recorded, _) = fixture(depth);
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
recorded.actions.list.push(action(vec![item(SCALE, scale(percent))]));
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none(), "{depth}/{percent}: {result}");
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + edits);
|
||||
equivalent(&recorded, &native);
|
||||
undo_redo(&mut recorded, &before, &after, edits);
|
||||
for single_state in [false, true] {
|
||||
for depth in [8, 16, 32] {
|
||||
for percent in [1.0, 100.0, 200.0, 1000.0] {
|
||||
let (mut native, native_source) = fixture(depth);
|
||||
let native_past = native.active().unwrap().history.past_len();
|
||||
native.execute(SCALE, json!({"scale":percent})).unwrap();
|
||||
scaled_shadow(&native, native_source, percent);
|
||||
let edits = native.active().unwrap().history.past_len() - native_past;
|
||||
let (mut recorded, _) = fixture(depth);
|
||||
recorded.execute("actions.setHistory", json!({"singleState":single_state})).unwrap();
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
recorded.actions.list.push(action(vec![item(SCALE, scale(percent))]));
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none(), "{depth}/{percent}: {result}");
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
// Grouped playback omits the unchanged 100% scale; per-step mode keeps native rows.
|
||||
let playback_edits = if single_state { usize::from(percent != 100.0) } else { edits };
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + playback_edits, "singleState={single_state}, depth={depth}, scale={percent}");
|
||||
equivalent(&recorded, &native);
|
||||
undo_redo(&mut recorded, &before, &after, playback_edits);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scaling then splitting keeps the same native result and two separately reversible edits.
|
||||
/// Scaling then splitting keeps native pixels with one grouped or two separately reversible edits.
|
||||
#[test]
|
||||
fn scale_then_create_layers_matches_native_sequence() {
|
||||
let (mut native, _) = fixture(16);
|
||||
native.execute(SCALE, json!({"scale":200})).unwrap();
|
||||
native.execute(CREATE, json!({})).unwrap();
|
||||
let (mut recorded, _) = fixture(16);
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
recorded.actions.list.push(action(vec![item(SCALE, scale(200.0)), item(CREATE, create())]));
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 2);
|
||||
assert!(result.get("failed").is_none());
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + 2);
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
equivalent(&recorded, &native);
|
||||
undo_redo(&mut recorded, &before, &after, 2);
|
||||
for single_state in [false, true] {
|
||||
let (mut native, _) = fixture(16);
|
||||
native.execute(SCALE, json!({"scale":200})).unwrap();
|
||||
native.execute(CREATE, json!({})).unwrap();
|
||||
let (mut recorded, _) = fixture(16);
|
||||
recorded.execute("actions.setHistory", json!({"singleState":single_state})).unwrap();
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
recorded.actions.list.push(action(vec![item(SCALE, scale(200.0)), item(CREATE, create())]));
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 2);
|
||||
assert!(result.get("failed").is_none());
|
||||
let edits = if single_state { 1 } else { 2 };
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + edits);
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
equivalent(&recorded, &native);
|
||||
if !single_state {
|
||||
// Per-step playback must also preserve the native intermediate scaled-effects state.
|
||||
assert!(native.undo());
|
||||
assert!(recorded.undo());
|
||||
equivalent(&recorded, &native);
|
||||
assert!(native.redo());
|
||||
assert!(recorded.redo());
|
||||
}
|
||||
undo_redo(&mut recorded, &before, &after, edits);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unedited wire identity/order/flags survive; a supported scale edit exports its new value.
|
||||
|
||||
@@ -680,10 +680,15 @@ fn artboard_edit_descriptor(background: i32, change_sizes: i32) -> Descriptor {
|
||||
fn artboard_background_edit_roundtrips_without_replaying_recorded_geometry() {
|
||||
for (kind, expected) in [(1, [1.0; 4]), (2, [0.0, 0.0, 0.0, 1.0]), (3, [0.0; 4]), (4, [0.25, 0.5, 0.75, 1.0])] {
|
||||
let mut s = artboard_fixture();
|
||||
s.execute("layer.new.artboardFromGroup", json!({})).unwrap();
|
||||
let initial_background = if kind == 1 { "black" } else { "white" };
|
||||
s.execute("layer.new.artboardFromGroup", json!({"background":initial_background})).unwrap();
|
||||
Arc::make_mut(&mut s.active_mut().unwrap().doc).guides.horizontal.push(3.0);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
assert_eq!(
|
||||
before.layer(id).unwrap().artboard().unwrap().background,
|
||||
if kind == 1 { photocraft_doc::ArtboardBackground::Black } else { photocraft_doc::ArtboardBackground::White }
|
||||
);
|
||||
let history = s.active().unwrap().history.past_len();
|
||||
let d = artboard_edit_descriptor(kind, 8);
|
||||
assert_eq!(load(&mut s, &file("editArtboardEvent", d.clone()))["unsupported"], json!([]));
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Recorded Type > Create Work Path preserves its descriptor and uses native glyph outlines.
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use photocraft_engine::{
|
||||
Session,
|
||||
actions_cmds::Action,
|
||||
atn_cmds::{export, import},
|
||||
};
|
||||
use photocraft_psd::{
|
||||
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
|
||||
descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const COMMAND: &str = "type.createWorkPath";
|
||||
const SOURCE: &str = "_atnTypePathDescriptor";
|
||||
|
||||
fn class(id: &str) -> Class {
|
||||
Class { name: UnicodeString::new_nul(""), class_id: Id::new(id) }
|
||||
}
|
||||
|
||||
/// Build the measured descriptor independently of the production converter.
|
||||
fn descriptor() -> Descriptor {
|
||||
Descriptor::new("null")
|
||||
.with("null", D::Reference(vec![ReferenceItem::Class(class("Path"))]))
|
||||
.with("From", D::Reference(vec![ReferenceItem::Enumerated { class: class("TxLr"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]))
|
||||
}
|
||||
|
||||
fn item(d: Descriptor) -> AtnItem {
|
||||
AtnItem { event: AtnEvent::Code(*b"Mk "), dictionary: b"Make".to_vec(), descriptor: Some(d), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Exercise the public byte importer rather than invoking the converter directly.
|
||||
fn load(item: AtnItem) -> photocraft_engine::atn_cmds::Imported {
|
||||
let bytes = atn::write(&AtnFile {
|
||||
name: UnicodeString::new_nul("Paths"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("Path"), items: vec![item], ..Default::default() }],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
import(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// Parse public export bytes so assertions inspect the actual saved ATN item.
|
||||
fn saved(action: &Action) -> AtnItem {
|
||||
let bytes = export("Paths", &[action], &[], false).unwrap().0;
|
||||
atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0)
|
||||
}
|
||||
|
||||
/// Names, key order, ID encoding and dialog flags must survive both Make event aliases.
|
||||
#[test]
|
||||
fn descriptor_identity_flags_and_make_alias_roundtrip() {
|
||||
for named in [false, true] {
|
||||
let mut d = descriptor();
|
||||
d.name = UnicodeString::new_nul("Recorded path");
|
||||
d.class_id = Id::Str(b"null".to_vec());
|
||||
d.items.reverse();
|
||||
let mut source = item(d);
|
||||
if named {
|
||||
source.event = AtnEvent::name("make");
|
||||
}
|
||||
source.enabled = false;
|
||||
source.with_dialog = true;
|
||||
source.dialog_options = 2;
|
||||
let loaded = load(source.clone());
|
||||
assert!(loaded.unsupported.is_empty());
|
||||
assert_eq!(loaded.actions[0].steps[0].0, COMMAND);
|
||||
let result = saved(&loaded.actions[0]);
|
||||
assert_eq!(result.descriptor.unwrap().to_bytes(), source.descriptor.unwrap().to_bytes());
|
||||
assert!(!result.enabled);
|
||||
assert!(result.with_dialog);
|
||||
assert_eq!(result.dialog_options, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare enabled canonical and retained-descriptor playback with native output at all depths.
|
||||
/// Glyph bounds distinguish the older active text layer from the newest layer independently.
|
||||
#[test]
|
||||
fn playback_matches_native_path_and_preserves_text_with_undo_redo() {
|
||||
for noncanonical in [false, true] {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut recorded = Session::new();
|
||||
recorded.execute("file.new", json!({"width":160,"height":100,"depth":depth})).unwrap();
|
||||
let text = recorded.execute("type.create", json!({"x":90,"y":80,"text":"ill","size":30})).unwrap();
|
||||
let text = LayerId(text["layer"].as_u64().unwrap());
|
||||
let other = recorded.execute("type.create", json!({"x":8,"y":35,"text":"o","size":24})).unwrap();
|
||||
let other = LayerId(other["layer"].as_u64().unwrap());
|
||||
assert_eq!(recorded.active().unwrap().active_layer, Some(other));
|
||||
recorded.select_layer(text).unwrap();
|
||||
let before = recorded.active().unwrap().doc.clone();
|
||||
let glyphs = before.layer(text).unwrap().surface().unwrap().content_bounds();
|
||||
let other_glyphs = before.layer(other).unwrap().surface().unwrap().content_bounds();
|
||||
let past = recorded.active().unwrap().history.past_len();
|
||||
// Obtain the native result on this very document, then restore the starting state.
|
||||
recorded.execute(COMMAND, json!({})).unwrap();
|
||||
let native = recorded.active().unwrap().doc.clone();
|
||||
assert!(!native.work_path.as_ref().unwrap().subpaths.is_empty());
|
||||
assert!(recorded.undo());
|
||||
assert_eq!(recorded.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
let mut d = descriptor();
|
||||
if noncanonical {
|
||||
d.items.reverse();
|
||||
d.name = UnicodeString::new_nul("Recorded path");
|
||||
}
|
||||
recorded.actions.list = load(item(d)).actions;
|
||||
if noncanonical {
|
||||
assert!(recorded.actions.list[0].steps[0].1.get(SOURCE).is_some());
|
||||
}
|
||||
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 1);
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
let after = recorded.active().unwrap().doc.clone();
|
||||
assert_eq!(after.as_ref(), native.as_ref());
|
||||
// Rendered glyph bounds are an independent oracle for the implicit active target.
|
||||
let (x0, y0, x1, y1) = after.work_path.as_ref().unwrap().control_bounds().unwrap();
|
||||
for (path, ink) in [(x0, glyphs.x0), (y0, glyphs.y0), (x1, glyphs.x1), (y1, glyphs.y1)] {
|
||||
assert!((path - f64::from(ink)).abs() < 3.0, "path {path}, active glyph bound {ink}");
|
||||
}
|
||||
assert!(x0 > f64::from(other_glyphs.x1), "the other layer's glyphs must not supply the work path");
|
||||
assert!(matches!(after.layer(text).unwrap().content, LayerContent::Text(_)));
|
||||
assert_eq!(recorded.active().unwrap().history.past_len(), past + 1);
|
||||
assert!(recorded.undo());
|
||||
assert_eq!(recorded.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert!(recorded.redo());
|
||||
assert_eq!(recorded.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown keys and unsupported reference chains remain verbatim kept steps, never guessed mappings.
|
||||
#[test]
|
||||
fn unsupported_path_forms_are_kept_and_save_without_loss() {
|
||||
let original = descriptor();
|
||||
let mut malformed = vec![
|
||||
original.clone().with("extra", D::Boolean(true)),
|
||||
original.clone().with("From", D::Boolean(false)),
|
||||
Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Path"))])),
|
||||
];
|
||||
for from in [
|
||||
D::Reference(vec![ReferenceItem::Name { class: class("TxLr"), name: UnicodeString::new_nul("Text") }]),
|
||||
D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]),
|
||||
D::Reference(vec![ReferenceItem::Enumerated { class: class("TxLr"), type_id: Id::new("Ordn"), value: Id::new("Al ") }]),
|
||||
D::Reference(vec![ReferenceItem::Class(class("TxLr"))]),
|
||||
] {
|
||||
let mut d = original.clone();
|
||||
d.items.iter_mut().find(|(k, _)| k.is("From")).unwrap().1 = from;
|
||||
malformed.push(d);
|
||||
}
|
||||
for d in malformed {
|
||||
let source = item(d);
|
||||
let loaded = load(source.clone());
|
||||
assert_eq!(loaded.unsupported.len(), 1);
|
||||
assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep");
|
||||
assert_eq!(saved(&loaded.actions[0]), source);
|
||||
}
|
||||
}
|
||||
|
||||
/// A missing document or non-text receiver must fail without changing document or history.
|
||||
#[test]
|
||||
fn non_text_and_no_document_playback_fail_without_edits() {
|
||||
for document in [false, true] {
|
||||
let mut s = Session::new();
|
||||
if document {
|
||||
s.execute("file.new", json!({"width":8,"height":8})).unwrap();
|
||||
}
|
||||
let before = s.active().map(|st| (st.doc.clone(), st.history.past_len()));
|
||||
s.actions.list = load(item(descriptor())).actions;
|
||||
let result = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert_eq!(result["ran"], 0);
|
||||
assert!(result.get("failed").is_some(), "{result}");
|
||||
assert_eq!(s.active().map(|st| (st.doc.clone(), st.history.past_len())), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit native targets and corrupt wire metadata cannot export as the implicit measured form.
|
||||
#[test]
|
||||
fn native_export_refuses_explicit_targets_and_damaged_metadata() {
|
||||
for params in [json!({"layer":1}), json!({"layers":[1]}), json!({(SOURCE):"bad base64"}), json!({(SOURCE):Value::Null})] {
|
||||
let action = Action::new("Path", vec![(COMMAND.into(), params)]);
|
||||
assert!(export("Paths", &[&action], &[], false).is_err());
|
||||
}
|
||||
let action = Action::new("Path", vec![(COMMAND.into(), json!({}))]);
|
||||
assert_eq!(saved(&action).descriptor.unwrap(), descriptor());
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod actions_single_history;
|
||||
mod affinity;
|
||||
mod artboard_export_names;
|
||||
mod atn_classic_gradient;
|
||||
mod atn_content_aware_fill;
|
||||
mod atn_crystallize_cell_size;
|
||||
mod atn_direct_adjustments;
|
||||
mod atn_layer_style;
|
||||
@@ -11,6 +14,7 @@ mod atn_ripple_amount;
|
||||
mod atn_selective_color;
|
||||
mod atn_shear;
|
||||
mod atn_stroke_width;
|
||||
mod atn_type_path;
|
||||
mod atn_v12;
|
||||
mod background_and_mask_depth;
|
||||
mod background_layer_consistency;
|
||||
|
||||
+138
-6
@@ -41,6 +41,13 @@ pub struct HistoryState {
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
/// The context in which a selected action row must remain available after it is folded away.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ActionBrushSource {
|
||||
Current(HistoryState),
|
||||
Cleared(HistoryState),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct History {
|
||||
/// Past states; the last one is the state *before* the current document.
|
||||
@@ -58,6 +65,10 @@ pub struct History {
|
||||
current_layers: LayerTarget,
|
||||
/// [`HistoryState::visibility`] of the current state.
|
||||
current_visibility: Vec<LayerId>,
|
||||
/// Optional compound-operation accumulator; snapshots and rollback carry it with the history.
|
||||
visibility_capture: Option<Arc<Vec<LayerId>>>,
|
||||
/// Changes only when every undo/redo state is irreversibly discarded.
|
||||
purge_generation: u64,
|
||||
/// History Options › Allow Non-Linear History: a step recorded while later states exist
|
||||
/// (after stepping back) keeps them and goes to the end of the list, instead of discarding
|
||||
/// them; see [`History::record`] and [`History::remove`].
|
||||
@@ -75,11 +86,15 @@ pub struct History {
|
||||
next_id: u64,
|
||||
/// The History Brush's source state, set in the History panel (`None`: the default).
|
||||
brush_source: Option<u64>,
|
||||
/// A snapshot-backed alias of a folded row. Clear History moves this with its row source;
|
||||
/// a snapshot selected explicitly by the user remains separate in the document state.
|
||||
brush_source_snapshot: Option<u64>,
|
||||
}
|
||||
|
||||
/// What [`History::rollback`] needs to take the most recent [`History::record`] back.
|
||||
#[derive(Clone, Debug)]
|
||||
struct LastRecord {
|
||||
visibility_capture_before: Option<Arc<Vec<LayerId>>>,
|
||||
/// The document it replaced (the state a rollback makes current again).
|
||||
before: Arc<Document>,
|
||||
/// How many former redo states it moved onto the undo side (always 0 in linear history).
|
||||
@@ -99,6 +114,7 @@ struct Cleared {
|
||||
undo: VecDeque<HistoryState>,
|
||||
redo: Vec<HistoryState>,
|
||||
brush_source: Option<u64>,
|
||||
brush_source_snapshot: Option<u64>,
|
||||
}
|
||||
|
||||
/// The undo label of the step [`History::clear_states`] adds (Edit › Undo Clear History).
|
||||
@@ -111,6 +127,7 @@ impl Default for History {
|
||||
}
|
||||
|
||||
impl History {
|
||||
/// Initialize history with the supplied document as its starting state and no active action visibility capture.
|
||||
pub fn new(max_states: usize) -> Self {
|
||||
Self {
|
||||
undo: VecDeque::new(),
|
||||
@@ -121,12 +138,15 @@ impl History {
|
||||
current_label: "Open".into(),
|
||||
current_layers: LayerTarget::default(),
|
||||
current_visibility: Vec::new(),
|
||||
visibility_capture: None,
|
||||
purge_generation: 0,
|
||||
non_linear: false,
|
||||
last_record: None,
|
||||
cleared: None,
|
||||
current_id: 0,
|
||||
next_id: 1,
|
||||
brush_source: None,
|
||||
brush_source_snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +199,7 @@ impl History {
|
||||
}
|
||||
evicted.push((at, state));
|
||||
}
|
||||
self.last_record = Some(LastRecord { before, moved, evicted });
|
||||
self.last_record = Some(LastRecord { before, moved, evicted, visibility_capture_before: self.visibility_capture.clone() });
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
@@ -221,6 +241,72 @@ impl History {
|
||||
/// again when a later change coalesces into the step).
|
||||
pub fn add_current_visibility(&mut self, ids: &[LayerId]) {
|
||||
merge_ids(&mut self.current_visibility, ids);
|
||||
if let Some(capture) = &mut self.visibility_capture {
|
||||
merge_ids(Arc::make_mut(capture), ids);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retain only changes that recorded edits make undoable, across trimming and local purges.
|
||||
pub fn begin_visibility_capture(&mut self, prefix: &[LayerId]) {
|
||||
self.visibility_capture = Some(Arc::new(prefix.to_vec()));
|
||||
}
|
||||
|
||||
/// Return the visibility prefix accumulated for the current grouped action, if capture is active.
|
||||
pub fn captured_visibility(&self) -> Option<&[LayerId]> {
|
||||
self.visibility_capture.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Release an action's accumulator when a purge leaves no grouped step to publish.
|
||||
pub fn end_visibility_capture(&mut self) {
|
||||
self.visibility_capture = None;
|
||||
}
|
||||
|
||||
/// An irreversible history purge establishes a new starting point for later edits.
|
||||
pub fn was_purged_since(&self, earlier: &Self) -> bool {
|
||||
self.purge_generation != earlier.purge_generation
|
||||
}
|
||||
|
||||
/// Keep source selections when temporary action rows are consolidated. Return a selected
|
||||
/// intermediate state so the caller can retain its pixels as a normal history snapshot.
|
||||
/// A source already deleted before consolidation remains unresolved.
|
||||
pub fn preserve_action_brush_source(&mut self, later: &Self) -> Option<ActionBrushSource> {
|
||||
let current = self.current_id;
|
||||
let prior_current = later.current_id;
|
||||
let collapsed = |id| {
|
||||
if id == prior_current || self.held_states().any(|state| state.id == id) { None } else { later.held_states().find(|state| state.id == id).cloned() }
|
||||
};
|
||||
let source = later
|
||||
.brush_source
|
||||
.and_then(collapsed)
|
||||
.map(ActionBrushSource::Current)
|
||||
.or_else(|| later.cleared.as_ref().and_then(|cleared| cleared.brush_source).and_then(collapsed).map(ActionBrushSource::Cleared));
|
||||
let map = |source: Option<u64>| source.map(|id| if id == prior_current { current } else { id });
|
||||
self.brush_source = map(later.brush_source);
|
||||
self.brush_source_snapshot = later.brush_source_snapshot;
|
||||
if let (Some(dst), Some(src)) = (&mut self.cleared, &later.cleared) {
|
||||
dst.brush_source = map(src.brush_source);
|
||||
dst.brush_source_snapshot = src.brush_source_snapshot;
|
||||
}
|
||||
source
|
||||
}
|
||||
|
||||
/// The snapshot retaining a folded row's pixels in the currently active source context.
|
||||
pub fn brush_source_snapshot(&self) -> Option<u64> {
|
||||
self.brush_source_snapshot
|
||||
}
|
||||
|
||||
/// Retain a folded row through a native snapshot, without adding a visible Undo state.
|
||||
pub fn set_action_brush_snapshot(&mut self, snapshot: u64) {
|
||||
self.brush_source = None;
|
||||
self.brush_source_snapshot = Some(snapshot);
|
||||
}
|
||||
|
||||
/// Keep a folded source inactive until Undo Clear History restores its source context.
|
||||
pub fn set_cleared_brush_snapshot(&mut self, snapshot: u64) {
|
||||
if let Some(cleared) = &mut self.cleared {
|
||||
cleared.brush_source = None;
|
||||
cleared.brush_source_snapshot = Some(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo: `current` becomes redoable; returns the document to make current and the layers it
|
||||
@@ -228,10 +314,11 @@ impl History {
|
||||
pub fn undo(&mut self, current: Arc<Document>) -> Option<(Arc<Document>, LayerTarget)> {
|
||||
// Undo Clear History: the cleared states come back; the document doesn't change.
|
||||
if let Some(cleared) = self.cleared.take() {
|
||||
let Cleared { undo, redo, brush_source } = *cleared;
|
||||
let Cleared { undo, redo, brush_source, brush_source_snapshot } = *cleared;
|
||||
self.undo = undo;
|
||||
self.redo = redo;
|
||||
self.brush_source = brush_source;
|
||||
self.brush_source_snapshot = brush_source_snapshot;
|
||||
self.tiles = None;
|
||||
return Some((current, self.current_layers.clone()));
|
||||
}
|
||||
@@ -244,6 +331,9 @@ impl History {
|
||||
let label = std::mem::replace(&mut self.current_label, prev.label);
|
||||
let layers = std::mem::replace(&mut self.current_layers, prev.layers.clone());
|
||||
let visibility = std::mem::replace(&mut self.current_visibility, prev.visibility);
|
||||
if let Some(capture) = &mut self.visibility_capture {
|
||||
merge_ids(Arc::make_mut(capture), &visibility);
|
||||
}
|
||||
let id = std::mem::replace(&mut self.current_id, prev.id);
|
||||
self.redo.push(HistoryState { label, doc: current, layers, visibility, id });
|
||||
Some((prev.doc, prev.layers))
|
||||
@@ -260,6 +350,9 @@ impl History {
|
||||
let label = std::mem::replace(&mut self.current_label, next.label);
|
||||
let layers = std::mem::replace(&mut self.current_layers, next.layers.clone());
|
||||
let visibility = std::mem::replace(&mut self.current_visibility, next.visibility);
|
||||
if let Some(capture) = &mut self.visibility_capture {
|
||||
merge_ids(Arc::make_mut(capture), &self.current_visibility);
|
||||
}
|
||||
let id = std::mem::replace(&mut self.current_id, next.id);
|
||||
self.undo.push_back(HistoryState { label, doc: current, layers, visibility, id });
|
||||
Some((next.doc, next.layers))
|
||||
@@ -287,6 +380,11 @@ impl History {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible History panel row ids in order, including the current and dimmed redo rows.
|
||||
pub fn row_ids(&self) -> impl Iterator<Item = u64> + '_ {
|
||||
self.undo.iter().map(|state| state.id).chain(std::iter::once(self.current_id)).chain(self.redo.iter().rev().map(|state| state.id))
|
||||
}
|
||||
|
||||
/// The History panel row of the state with id `id`, if it is still held.
|
||||
pub fn row_of(&self, id: u64) -> Option<usize> {
|
||||
let past = self.undo.len();
|
||||
@@ -316,6 +414,7 @@ impl History {
|
||||
pub fn set_brush_source(&mut self, i: usize) -> bool {
|
||||
let Some(id) = self.row_id(i) else { return false };
|
||||
self.brush_source = Some(id);
|
||||
self.brush_source_snapshot = None;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -339,7 +438,17 @@ impl History {
|
||||
self.current_label = label.into();
|
||||
}
|
||||
|
||||
/// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the
|
||||
/// The user's Edit › Purge › Undo also starts a new action-history boundary. Internal
|
||||
/// compound commands use [`Self::purge_last`] to fold their records without this boundary.
|
||||
pub fn purge_undo(&mut self) -> bool {
|
||||
let purged = self.purge_last();
|
||||
if purged {
|
||||
self.purge_generation = self.purge_generation.wrapping_add(1);
|
||||
}
|
||||
purged
|
||||
}
|
||||
|
||||
/// Forget the most recent undo state and every redo state: the
|
||||
/// last step can no longer be undone and its pixels are released.
|
||||
pub fn purge_last(&mut self) -> bool {
|
||||
self.clear_redo();
|
||||
@@ -429,7 +538,8 @@ impl History {
|
||||
if self.undo.len().saturating_add(held.evicted.len()) <= held.moved {
|
||||
return None;
|
||||
}
|
||||
let LastRecord { moved, evicted, .. } = self.last_record.take()?;
|
||||
let LastRecord { moved, evicted, visibility_capture_before, .. } = self.last_record.take()?;
|
||||
self.visibility_capture = visibility_capture_before;
|
||||
// Put each back where it was taken off, the last one taken off first.
|
||||
for (at, state) in evicted.into_iter().rev() {
|
||||
if let Some(tiles) = &mut self.tiles {
|
||||
@@ -459,6 +569,12 @@ impl History {
|
||||
self.last_record.as_ref().map(|held| held.before.clone())
|
||||
}
|
||||
|
||||
/// Keep identifiers consumed by a completed compound operation reserved when its history
|
||||
/// is consolidated from an earlier snapshot. Removed rows must not name a later state.
|
||||
pub fn reserve_ids_from(&mut self, later: &Self) {
|
||||
self.next_id = self.next_id.max(later.next_id);
|
||||
}
|
||||
|
||||
/// Forget the redo states, e.g. after undoing half of a compound step that failed.
|
||||
pub fn clear_redo(&mut self) {
|
||||
if !self.redo.is_empty() {
|
||||
@@ -476,6 +592,7 @@ impl History {
|
||||
/// goes back to the default, the oldest state held (now the current one): a source set on a
|
||||
/// state the purge removed would otherwise leave no source.
|
||||
pub fn clear(&mut self) {
|
||||
self.purge_generation = self.purge_generation.wrapping_add(1);
|
||||
self.last_record = None;
|
||||
self.current_visibility.clear();
|
||||
self.undo.clear();
|
||||
@@ -488,6 +605,7 @@ impl History {
|
||||
/// Make the History Brush's source the default again (the oldest state held), pinning no state.
|
||||
pub fn reset_brush_source(&mut self) {
|
||||
self.brush_source = None;
|
||||
self.brush_source_snapshot = None;
|
||||
}
|
||||
|
||||
/// History panel › Clear History: take every state but the current one off the list without
|
||||
@@ -499,13 +617,27 @@ impl History {
|
||||
if self.undo.is_empty() && self.redo.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.clear_states_including_empty();
|
||||
true
|
||||
}
|
||||
|
||||
/// Preserve a Clear already accepted during an action when its command-local rows fold
|
||||
/// into this captured history, which may have no rows. Ordinary empty Clear still refuses.
|
||||
pub fn preserve_action_clear(&mut self, later: &Self) {
|
||||
if later.is_cleared() && !self.is_cleared() {
|
||||
self.clear_states_including_empty();
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an accepted Clear to an empty captured history as well as populated histories, preserving its boundary.
|
||||
fn clear_states_including_empty(&mut self) {
|
||||
let undo = std::mem::take(&mut self.undo);
|
||||
let redo = std::mem::take(&mut self.redo);
|
||||
let brush_source = self.brush_source.take();
|
||||
self.cleared = Some(Box::new(Cleared { undo, redo, brush_source }));
|
||||
let brush_source_snapshot = self.brush_source_snapshot.take();
|
||||
self.cleared = Some(Box::new(Cleared { undo, redo, brush_source, brush_source_snapshot }));
|
||||
self.last_record = None;
|
||||
self.tiles = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the last change to the list was Clear History (so undo restores the states).
|
||||
|
||||
@@ -14,6 +14,31 @@ fn edit(h: &mut History, cur: &mut Arc<Document>, label: &str, f: impl FnOnce(&m
|
||||
h.record(label, before, LayerTarget::default());
|
||||
}
|
||||
|
||||
/// A failed local transaction drops its visibility flags; a successful local purge keeps them.
|
||||
#[test]
|
||||
fn visibility_capture_follows_rollback_snapshot_and_purge() {
|
||||
let mut doc = base();
|
||||
doc.insert_above(None, Layer::raster("Second", doc.pixel_format()));
|
||||
let ids = [doc.layers[0].id, doc.layers[1].id];
|
||||
let mut cur = Arc::new(doc);
|
||||
let mut history = History::default();
|
||||
history.begin_visibility_capture(&[]);
|
||||
edit(&mut history, &mut cur, "Prefix", |doc| doc.layer_mut(ids[0]).unwrap().visible = false);
|
||||
history.add_current_visibility(&ids[..1]);
|
||||
let prefix = cur.clone();
|
||||
let checkpoint = history.clone();
|
||||
edit(&mut history, &mut cur, "Failed part", |doc| doc.layer_mut(ids[1]).unwrap().visible = false);
|
||||
history.add_current_visibility(&ids[1..]);
|
||||
cur = history.rollback().unwrap().0;
|
||||
assert_eq!(cur.as_ref(), prefix.as_ref());
|
||||
assert_eq!(history.captured_visibility(), Some(ids[..1].as_ref()));
|
||||
assert_eq!(checkpoint.captured_visibility(), Some(ids[..1].as_ref()), "a cloned snapshot is isolated");
|
||||
edit(&mut history, &mut cur, "Successful part", |doc| doc.layer_mut(ids[1]).unwrap().visible = false);
|
||||
history.add_current_visibility(&ids[1..]);
|
||||
assert!(history.purge_last());
|
||||
assert_eq!(history.captured_visibility(), Some(ids.as_ref()), "purging a local record keeps its actual change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_redo_cycle() {
|
||||
let mut h = History::default();
|
||||
@@ -1133,6 +1158,7 @@ fn transferred_tiles_cannot_make_the_budget_check_underestimate_current() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Clear History changes the state list without changing pixels and remains undoable where the operation permits it.
|
||||
fn clear_states_keeps_the_document_and_undo_brings_the_list_back() {
|
||||
let mut h = History::new(10);
|
||||
let mut cur = Arc::new(base());
|
||||
@@ -1156,6 +1182,34 @@ fn clear_states_keeps_the_document_and_undo_brings_the_list_back() {
|
||||
assert!(!h.is_cleared());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Finalizing a grouped action must not discard an accepted Clear merely because its captured history was empty.
|
||||
fn an_action_preserves_an_accepted_clear_over_an_empty_capture() {
|
||||
let mut captured = History::new(50);
|
||||
assert!(!captured.clear_states(), "ordinary empty Clear still refuses");
|
||||
captured.preserve_action_clear(&History::new(50));
|
||||
assert!(!captured.is_cleared(), "no boundary without an accepted Clear");
|
||||
let mut later = captured.clone();
|
||||
let original = Arc::new(base());
|
||||
let mut current = original.clone();
|
||||
edit(&mut later, &mut current, "Black source", |doc| doc.name = "black".into());
|
||||
assert!(later.set_brush_source(1));
|
||||
current = later.undo(current).unwrap().0;
|
||||
assert!(Arc::ptr_eq(¤t, &original));
|
||||
assert!(later.clear_states(), "the live redo row makes Clear legitimate");
|
||||
captured.preserve_action_clear(&later);
|
||||
assert!(captured.is_cleared() && captured.can_undo());
|
||||
assert_eq!(captured.undo_label(), Some(CLEAR_HISTORY_LABEL));
|
||||
let Some(ActionBrushSource::Cleared(source)) = captured.preserve_action_brush_source(&later) else { panic!("the exact cleared source must survive") };
|
||||
assert_eq!(source.doc.name, "black");
|
||||
captured.set_cleared_brush_snapshot(7);
|
||||
let restored = captured.undo(current.clone()).unwrap().0;
|
||||
assert!(Arc::ptr_eq(&restored, ¤t), "Undo Clear restores the list, not pixels");
|
||||
assert!(!captured.is_cleared());
|
||||
assert_eq!(captured.brush_source_snapshot(), Some(7));
|
||||
assert_eq!(captured.past_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_states_still_count_in_the_memory_figures_and_the_budget() {
|
||||
let mut h = History::new(50);
|
||||
|
||||
@@ -313,7 +313,7 @@ fn choose_crop(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) ->
|
||||
use crate::crop_overlay::{CropOverlay, OverlayShow};
|
||||
use crate::crop_presets::{Choice, apply_choice};
|
||||
match command {
|
||||
CROP_CANCEL => crate::crop_ui::cancel(app),
|
||||
CROP_CANCEL => crate::crop_ui::dismiss(app),
|
||||
CROP_COMMIT => crate::canvas::commit_crop(app),
|
||||
CROP_NEW => apply_choice(app, Choice::New),
|
||||
CROP_DELETE => apply_choice(app, Choice::Delete),
|
||||
|
||||
@@ -1394,4 +1394,8 @@ fn crop_menu_rows_act_like_the_options_bar() {
|
||||
reopen(&mut app);
|
||||
choose(&mut app, &ctx, CROP_CANCEL);
|
||||
assert!(!crate::crop_ui::pending(&app));
|
||||
// ...and, as Esc does, hides the frame without cropping (#10370).
|
||||
crate::crop_ui::ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None);
|
||||
assert_eq!(app.session.active().unwrap().doc.size, size);
|
||||
}
|
||||
@@ -890,7 +890,7 @@ fn ui_set_drives_the_layers_panel_artboard_filter() {
|
||||
assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard);
|
||||
let bad = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboards"}}));
|
||||
assert_eq!(bad["ok"], false, "{bad}");
|
||||
assert!(bad.to_string().contains("kind, name, effect, mode, color, smartObject or artboard"), "{bad}");
|
||||
assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject or artboard"), "{bad}");
|
||||
assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard, "a bad value changed nothing");
|
||||
}
|
||||
|
||||
@@ -928,9 +928,9 @@ fn ui_set_drives_the_layers_panel_filter() {
|
||||
let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "name", "name": "ink"}}));
|
||||
assert_eq!(ok["ok"], true, "{ok}");
|
||||
assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_name.as_str()), (FilterBy::Name, "ink"));
|
||||
let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "mode", "mode": "Pass Through", "kinds": ["type", "pixel", "type"]}}));
|
||||
let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "mode", "mode": "Multiply", "kinds": ["type", "pixel", "type"]}}));
|
||||
assert_eq!(ok["ok"], true, "{ok}");
|
||||
assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_mode), (FilterBy::Mode, photocraft_color::BlendMode::PassThrough));
|
||||
assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_mode), (FilterBy::Mode, photocraft_color::BlendMode::Multiply));
|
||||
assert_eq!(app.ui.layer_filter, ["type", "pixel"], "kinds are kept once each");
|
||||
assert_eq!(app.ui.layer_filter_name, "ink", "fields left out keep their value");
|
||||
for bad in [
|
||||
@@ -940,6 +940,8 @@ fn ui_set_drives_the_layers_panel_filter() {
|
||||
json!({"layerFilter": {"kinds": "pixel"}}),
|
||||
json!({"layerFilter": {"name": 3}}),
|
||||
json!({"layerFilter": {"mode": "Nonsense"}}),
|
||||
// The Mode list has no Pass Through (#10878).
|
||||
json!({"layerFilter": {"mode": "Pass Through"}}),
|
||||
json!({"layerFilter": {"color": "crimson"}}),
|
||||
json!({"layerFilter": {"by": "kind", "extra": true}}),
|
||||
json!({"layerFilter": {"by": "kind"}, "tileGrid": "yes"}),
|
||||
@@ -947,6 +949,7 @@ fn ui_set_drives_the_layers_panel_filter() {
|
||||
let r = call(&mut app, &ctx, "ui.set", bad.clone());
|
||||
assert_eq!(r["ok"], false, "{bad} → {r}");
|
||||
assert_eq!(app.ui.layer_filter_by, FilterBy::Mode, "{bad} changed nothing");
|
||||
assert_eq!(app.ui.layer_filter_mode, photocraft_color::BlendMode::Multiply, "{bad} left the mode alone");
|
||||
}
|
||||
// #5898: the Color filter through the same path.
|
||||
let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "color", "color": "red"}}));
|
||||
|
||||
@@ -931,10 +931,11 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result<Value,
|
||||
Ok(edit) => edit,
|
||||
Err(e) => {
|
||||
app.filter_preview = None;
|
||||
crate::actions::abandoned(app, &d.fields);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let result = app.run(&cmd, params.clone());
|
||||
let result = crate::actions::run_step_dialog(app, &d.fields, &cmd, params.clone());
|
||||
// A filter that runs as a background job keeps its preview on screen until the job
|
||||
// lands (`canvas::committed_filter_preview`) instead of flashing the unfiltered image.
|
||||
let running =
|
||||
|
||||
@@ -1129,7 +1129,7 @@ pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, command: String, fields:
|
||||
fields.insert("mapPath".into(), json!(path));
|
||||
app.refocus(doc)?;
|
||||
let (cmd, params) = crate::smart_ui::confirm_command(app, &fields, command, params_of(&fields))?;
|
||||
let result = app.run(&cmd, params);
|
||||
let result = crate::actions::run_step_dialog(app, &fields, &cmd, params);
|
||||
if result.is_ok() {
|
||||
remember(app, &cmd, &fields);
|
||||
let _ = crate::actions::resume(app, &fields);
|
||||
|
||||
@@ -819,7 +819,7 @@ fn handle_registry_and_tool_keys(app: &mut PhotocraftApp, ctx: &egui::Context, f
|
||||
app.ui.polygon.clear();
|
||||
app.ui.polygon_mode.clear();
|
||||
app.ui.polygon_doc = None;
|
||||
crate::crop_ui::cancel(app);
|
||||
crate::crop_ui::dismiss(app);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,9 @@ pub fn disabled(app: &crate::PhotocraftApp, id: &str) -> bool {
|
||||
// warp, and doesn't distort while editable type is among them.
|
||||
if transform {
|
||||
let targets = photocraft_engine::transform_cmds::selection_targets(st);
|
||||
// Judged on the layers the box moves, linked ones included (#11217): one linked layer
|
||||
// selected is a box over its link group, which doesn't warp either.
|
||||
let targets = photocraft_engine::transform_cmds::box_targets(&st.doc, &targets);
|
||||
if targets.len() > 1 || targets.first() != st.active_layer.as_ref() {
|
||||
let text = targets.iter().any(|t| st.doc.layer(*t).is_some_and(|l| matches!(l.content, LayerContent::Text(_))));
|
||||
return match id {
|
||||
@@ -362,6 +365,27 @@ mod tests {
|
||||
assert_eq!(checked, selections.len() * 2 * commands.len());
|
||||
}
|
||||
|
||||
/// #11217 review: one linked layer selected opens a box over its link group, which refuses
|
||||
/// Warp, so the menu greys Warp the same way it does for several selected layers; an unlinked
|
||||
/// layer keeps it.
|
||||
#[test]
|
||||
fn warp_is_greyed_for_a_layer_with_linked_partners() {
|
||||
let mut s = session();
|
||||
let id = |r: serde_json::Value| photocraft_doc::LayerId(r["layer"].as_u64().unwrap());
|
||||
let a = id(s.execute("layer.new.layer", json!({})).unwrap());
|
||||
let b = id(s.execute("layer.new.layer", json!({})).unwrap());
|
||||
let c = id(s.execute("layer.new.layer", json!({})).unwrap());
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
let mut app = crate::PhotocraftApp::new(s, crate::Services::default());
|
||||
assert!(disabled(&app, "edit.transform.warp"), "a linked layer warps with its partner: not available");
|
||||
assert!(!disabled(&app, "edit.freeTransform") && !disabled(&app, "edit.transform.distort"), "the box itself is");
|
||||
app.session.execute("layer.select", json!({"layer": c.0})).unwrap();
|
||||
assert!(!disabled(&app, "edit.transform.warp"), "an unlinked layer warps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_layers_keep_their_items() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -196,6 +196,7 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option<Value>)
|
||||
let (action, from) = match actions_cmds::playback_plan(&app.session, params) {
|
||||
Ok(plan) => plan,
|
||||
Err(e) => {
|
||||
actions_cmds::finish_pending_history(&mut app.session);
|
||||
restore_recording(app, suspended.as_ref());
|
||||
return Err(e.to_string());
|
||||
}
|
||||
@@ -203,6 +204,7 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option<Value>)
|
||||
let recording = match actions_cmds::begin_playback(&mut app.session, &action) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
actions_cmds::finish_pending_history(&mut app.session);
|
||||
restore_recording(app, suspended.as_ref());
|
||||
return Err(e.to_string());
|
||||
}
|
||||
@@ -241,14 +243,25 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option<Value>)
|
||||
// from the next step when it is confirmed (`resume`). Agents play headlessly.
|
||||
if action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id)) {
|
||||
let label = label_of(id);
|
||||
let active_layer = app.session.active().and_then(|st| st.active_layer);
|
||||
let dialog = crate::dialogs::open_command_dialog(app, id, &label);
|
||||
let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize);
|
||||
let mut receiver = None;
|
||||
if let Some(d) = app.ui.dialog_mut(dialog) {
|
||||
if let Some(o) = p.as_object() {
|
||||
// `__` keys are the dialog's own markers (`params_of` drops them), not step values.
|
||||
d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
d.fields.insert(RESUME.into(), json!(token));
|
||||
// Kept for every implicit step, also one opened on a document target: the dialog
|
||||
// may switch it to a layer-owned one, and `run_step_dialog` checks the receiver
|
||||
// for the target it confirms.
|
||||
if p.get("layer").is_none_or(Value::is_null) {
|
||||
receiver = Some(active_layer);
|
||||
}
|
||||
}
|
||||
if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) {
|
||||
paused.filter_receiver = receiver;
|
||||
}
|
||||
paused = Some(json!({"step": i, "id": id, "dialog": dialog}));
|
||||
break;
|
||||
@@ -317,19 +330,29 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option<Value>)
|
||||
}
|
||||
}
|
||||
}
|
||||
let history = if failed.is_none() && paused.is_some() { actions_cmds::suspend_history(&mut app.session) } else { None };
|
||||
actions_cmds::finish_playback(&mut app.session, None, &action, from);
|
||||
match paused.as_ref().and_then(waiting_dialog).cloned() {
|
||||
// Paused for a dialog: the call goes on after it, so recording stays suspended.
|
||||
Some(dialog) if failed.is_none() => {
|
||||
let token = paused_token(app, &dialog);
|
||||
let entry = token.and_then(|t| app.paused_playbacks.paused.get_mut(&t));
|
||||
match (owner, entry) {
|
||||
(Some(owner), Some(p)) => p.record = Some(owner),
|
||||
match entry {
|
||||
Some(p) => {
|
||||
p.record = owner;
|
||||
p.history = history;
|
||||
}
|
||||
// No continuation to carry the recording: the call ends here.
|
||||
(owner, _) => restore_recording(app, owner.as_ref()),
|
||||
_ => {
|
||||
drop(history);
|
||||
restore_recording(app, owner.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => restore_recording(app, owner.as_ref()),
|
||||
_ => {
|
||||
drop(history);
|
||||
restore_recording(app, owner.as_ref());
|
||||
}
|
||||
}
|
||||
Ok(match (failed, paused) {
|
||||
(Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}),
|
||||
@@ -802,13 +825,28 @@ pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), Strin
|
||||
|
||||
/// A step dialog's command failed: the action ends there.
|
||||
pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>) {
|
||||
let record = fields.get(RESUME).and_then(|t| app.paused_playbacks.take(t)).and_then(|p| p.record);
|
||||
let paused = fields.get(RESUME).and_then(|t| app.paused_playbacks.take(t));
|
||||
let record = paused.and_then(|p| {
|
||||
drop(p.history);
|
||||
p.record
|
||||
});
|
||||
actions_cmds::finish_pending_history(&mut app.session);
|
||||
restore_recording(app, record.as_ref());
|
||||
}
|
||||
|
||||
/// The automation gate a session installs ([`photocraft_engine::Session::authorize`]).
|
||||
type Gate = fn(&str, &Value) -> photocraft_engine::Result<()>;
|
||||
|
||||
/// A function-pointer dispatch gate cannot carry two different policies into nested jobs.
|
||||
/// Changed gates and distinct simultaneous Session/service gates both end this continuation;
|
||||
/// hosts that need both policies must supply one composed gate at both boundaries.
|
||||
fn continuation_gate(captured: Option<Gate>, live: Option<Gate>) -> Result<Option<Gate>, String> {
|
||||
if captured.zip(live).is_some_and(|(before, now)| !std::ptr::fn_addr_eq(before, now)) {
|
||||
return Err("the authorization context changed or uses policies that cannot be combined for this paused action".into());
|
||||
}
|
||||
Ok(live.or(captured))
|
||||
}
|
||||
|
||||
/// A playback paused at a step dialog: the action and step it continues from, and the gate it
|
||||
/// was playing under (`None` for one a person started).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -822,6 +860,7 @@ pub(crate) struct PausedPlayback {
|
||||
/// box, automation), which end the call through `resume`, `abandoned` or `stopped`, so the
|
||||
/// recording comes back exactly once.
|
||||
record: Option<Value>,
|
||||
history: Option<actions_cmds::ActionHistoryContinuation>,
|
||||
/// The actions still to play after this one, as they were when Play started a whole set.
|
||||
/// Actions have no id, so the whole action stands in for one: an action deleted and made again
|
||||
/// under the same name, or edited, while the dialog is open no longer matches.
|
||||
@@ -832,6 +871,9 @@ pub(crate) struct PausedPlayback {
|
||||
/// The document a pause at Check Spelling was playing on. That dialog doesn't block the
|
||||
/// document tabs, so Done ([`resume_spelling`]) continues only on this document.
|
||||
doc: Option<photocraft_doc::DocId>,
|
||||
/// An implicit filter/adjustment step resolves this active layer at the pause. The outer Option marks
|
||||
/// that binding even when there was no active layer; explicit-layer and other dialogs omit it.
|
||||
filter_receiver: Option<Option<photocraft_doc::LayerId>>,
|
||||
}
|
||||
|
||||
/// The paused playbacks, by the token their dialog carries. The dialog's fields hold only the
|
||||
@@ -843,17 +885,106 @@ pub(crate) struct PausedPlaybacks {
|
||||
}
|
||||
|
||||
impl PausedPlaybacks {
|
||||
/// Allocate an opaque token retaining the action, resume position and captured authorization gate.
|
||||
fn pause(&mut self, action: String, from: usize, gate: Option<Gate>) -> u64 {
|
||||
self.last = self.last.wrapping_add(1);
|
||||
self.paused.insert(self.last, PausedPlayback { action, from, gate, record: None, then: Vec::new(), ran: 0, doc: None });
|
||||
self.paused
|
||||
.insert(self.last, PausedPlayback { action, from, gate, record: None, history: None, then: Vec::new(), ran: 0, doc: None, filter_receiver: None });
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Consume the continuation named by a numeric dialog token; malformed or expired tokens identify no playback.
|
||||
fn take(&mut self, token: &Value) -> Option<PausedPlayback> {
|
||||
token.as_u64().and_then(|token| self.paused.remove(&token))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a dialog command's `target` belongs to the document rather than to a layer: alpha
|
||||
/// channels and Quick Mask do; masks and colour components still belong to a layer.
|
||||
fn document_target(params: &Value) -> bool {
|
||||
params.get("target").is_some_and(|target| target.as_str() == Some("quickMask") || target.get("channel").and_then(Value::as_u64).is_some())
|
||||
}
|
||||
|
||||
/// Admit the final dialog command before resuming history or starting a worker. The captured
|
||||
/// policy judges the edited parameters; a current live gate still governs dispatch and nested
|
||||
/// work. With no live gate, reinstall the captured gate for job admission and rendering.
|
||||
pub(crate) fn run_step_dialog(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>, command: &str, params: Value) -> Result<Value, String> {
|
||||
let Some(token) = fields.get(RESUME) else { return app.run(command, params) };
|
||||
let token = token.as_u64().ok_or("this dialog doesn't belong to a paused action")?;
|
||||
let paused = app.paused_playbacks.paused.get(&token).ok_or("this dialog doesn't belong to a paused action")?;
|
||||
if let Some(receiver) = paused.filter_receiver
|
||||
&& !document_target(¶ms)
|
||||
&& app.session.active().and_then(|st| st.active_layer) != receiver
|
||||
{
|
||||
return Err("the paused command's receiver changed while its dialog was open".into());
|
||||
}
|
||||
let captured = paused.gate;
|
||||
let saved = app.session.authorize;
|
||||
let service = if app.automation_input { app.services.automation_authorize } else { None };
|
||||
for authorize in [captured, saved, service].into_iter().flatten() {
|
||||
authorize(command, ¶ms).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if app.automation_input
|
||||
&& let Some(authorize) = app.services.automation_command.as_ref()
|
||||
{
|
||||
authorize(command, ¶ms)?;
|
||||
}
|
||||
let gate = continuation_gate(captured, continuation_gate(saved, service)?)?;
|
||||
prepare_history(app, fields)?;
|
||||
app.session.authorize = gate;
|
||||
let result = app.run(command, params);
|
||||
app.session.authorize = saved;
|
||||
result
|
||||
}
|
||||
|
||||
/// Bind the dialog's own edit to its suspended action before it runs or starts a worker.
|
||||
pub(crate) fn prepare_history(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>) -> Result<(), String> {
|
||||
let Some(token) = fields.get(RESUME).and_then(Value::as_u64) else { return Ok(()) };
|
||||
let history = app.paused_playbacks.paused.get_mut(&token).ok_or("this dialog doesn't belong to a paused action")?.history.take();
|
||||
if let Some(history) = history
|
||||
&& let Err(e) = history.resume(&mut app.session)
|
||||
{
|
||||
if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) {
|
||||
paused.history = Some(history);
|
||||
}
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check Spelling owns each correction only while it runs; the open window owns a bound token.
|
||||
pub(crate) fn spelling_change(app: &mut PhotocraftApp, params: Value) -> Result<Value, String> {
|
||||
let Some(token) = app.ui.type_panels.spell.as_ref().and_then(|dialog| dialog.resume) else {
|
||||
return app.run("edit.checkSpelling", params);
|
||||
};
|
||||
let paused = app.paused_playbacks.paused.get(&token).ok_or("this dialog doesn't belong to a paused action")?;
|
||||
if paused.doc.is_some_and(|doc| Some(doc) != app.session.active().map(|state| state.doc.id)) {
|
||||
return Err("the active document changed while Check Spelling was open".into());
|
||||
}
|
||||
let history = paused.history.clone();
|
||||
let fields = serde_json::Map::from_iter([(RESUME.to_string(), json!(token))]);
|
||||
let result = run_step_dialog(app, &fields, "edit.checkSpelling", params);
|
||||
// A rejected admission did not resume this continuation. Keep the original token intact.
|
||||
if app.paused_playbacks.paused.get(&token).is_some_and(|paused| paused.history.is_some()) {
|
||||
return result;
|
||||
}
|
||||
if let Some(history) = history {
|
||||
match history.suspend_after_edit(&mut app.session) {
|
||||
Ok(history) => {
|
||||
let paused = app.paused_playbacks.paused.get_mut(&token).ok_or("the spelling continuation ended during its correction")?;
|
||||
paused.history = Some(history);
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) {
|
||||
paused.history = Some(history);
|
||||
}
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Check Spelling's Done for the action paused at its step: the action continues when the document
|
||||
/// it was playing on is still the active one, and otherwise ends there with the reason in the
|
||||
/// status bar. Returns `Ok` when nothing failed.
|
||||
@@ -881,10 +1012,8 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>)
|
||||
return Err("this dialog doesn't belong to a paused action".into());
|
||||
};
|
||||
let owner = paused.record.clone();
|
||||
// The dialog's command may run as a background job; the next step needs its result.
|
||||
if let Some(doc) = app.session.active().map(|d| d.doc.id)
|
||||
&& let Some(job) = app.session.job_on(doc)
|
||||
&& let Err(e) = app.session.wait_job(job.id)
|
||||
if let Some(history) = &paused.history
|
||||
&& let Err(e) = history.resume(&mut app.session)
|
||||
{
|
||||
restore_recording(app, owner.as_ref());
|
||||
let template = "Action playback failed: {error}";
|
||||
@@ -892,9 +1021,43 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>)
|
||||
app.ui.status_error = true;
|
||||
return Err(e.to_string());
|
||||
}
|
||||
let params = json!({"action": paused.action, "from": paused.from});
|
||||
// The dialog's command may run as a background job; the next step needs its result.
|
||||
if let Some(doc) = app.session.active().map(|d| d.doc.id)
|
||||
&& let Some(job) = app.session.job_on(doc)
|
||||
&& let Err(e) = app.session.wait_job(job.id)
|
||||
{
|
||||
actions_cmds::finish_pending_history(&mut app.session);
|
||||
restore_recording(app, owner.as_ref());
|
||||
let template = "Action playback failed: {error}";
|
||||
app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e.to_string())]);
|
||||
app.ui.status_error = true;
|
||||
return Err(e.to_string());
|
||||
}
|
||||
let saved = app.session.authorize;
|
||||
app.session.authorize = paused.gate.or(saved);
|
||||
let service = if app.automation_input { app.services.automation_authorize } else { None };
|
||||
let remaining = !paused.then.is_empty()
|
||||
|| app
|
||||
.session
|
||||
.actions
|
||||
.list
|
||||
.iter()
|
||||
.find(|action| action.name == paused.action)
|
||||
.is_some_and(|action| action.enabled_steps().any(|(i, _)| i >= paused.from));
|
||||
let gate =
|
||||
if remaining { continuation_gate(saved, service).and_then(|live| continuation_gate(paused.gate, live)) } else { Ok(saved.or(service).or(paused.gate)) };
|
||||
let gate = match gate {
|
||||
Ok(gate) => gate,
|
||||
Err(error) => {
|
||||
// The admitted dialog edit has completed; preserve it while rejecting the tail.
|
||||
actions_cmds::finish_pending_history(&mut app.session);
|
||||
restore_recording(app, owner.as_ref());
|
||||
app.ui.status = error.clone();
|
||||
app.ui.status_error = true;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let params = json!({"action": paused.action, "from": paused.from});
|
||||
app.session.authorize = gate;
|
||||
let mut played = play_owned(app, ¶ms, owner);
|
||||
// Paused again at a later dialog of the same action (not of one it calls, which resumes
|
||||
// alone): that dialog carries the rest of the set.
|
||||
|
||||
@@ -16,6 +16,214 @@ fn action_set_bytes(set: &str, action: &str) -> Vec<u8> {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Dialog confirmation, worker completion and cancellation retain the original action boundary.
|
||||
#[test]
|
||||
fn single_history_dialog_confirmation_and_cancel() {
|
||||
for (single, confirm, background) in [(true, true, false), (true, true, true), (true, false, false), (false, true, false)] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width":4,"height":4,"background":"#804020"})).unwrap();
|
||||
app.run("actions.setHistory", json!({"singleState":single})).unwrap();
|
||||
app.background_jobs = background;
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let count = app.session.active().unwrap().history.past_len();
|
||||
let mut action = Action::new(
|
||||
"Dialog",
|
||||
vec![
|
||||
("layer.renameLayer".into(), json!({"name":"Prefix"})),
|
||||
("filter.blur.gaussianBlur".into(), json!({"radius":1})),
|
||||
("layer.renameLayer".into(), json!({"name":"Suffix"})),
|
||||
],
|
||||
);
|
||||
action.dialogs.insert(1);
|
||||
app.session.actions.list.push(action);
|
||||
let paused = play(&mut app, &json!({"action":"Dialog"})).unwrap();
|
||||
let dialog = paused["paused"]["dialog"].as_u64().unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), count + 1);
|
||||
if confirm {
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
} else {
|
||||
crate::dialogs::cancel(&mut app, dialog).unwrap();
|
||||
}
|
||||
let st = app.session.active().unwrap();
|
||||
let after = st.doc.clone();
|
||||
assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, if confirm { "Suffix" } else { "Prefix" });
|
||||
if single {
|
||||
assert_eq!(st.history.past_len(), count + 1);
|
||||
assert_eq!(st.history.undo_label(), Some("Action: Dialog"));
|
||||
} else {
|
||||
assert!(st.history.past_len() >= count + 2);
|
||||
}
|
||||
assert!(app.session.undo());
|
||||
if single {
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
} else {
|
||||
assert_ne!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
}
|
||||
assert!(app.session.redo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
assert_eq!(app.session.actions.playing, 0);
|
||||
let count = app.session.active().unwrap().history.past_len();
|
||||
app.session.execute("layer.renameLayer", json!({"name":"Outside"})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), count + 1);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// A stale dialog must reject its own edit before it can change an unrelated document/history.
|
||||
#[test]
|
||||
fn single_history_dialog_refuses_intervening_edit_or_document_switch() {
|
||||
for (single_state, switch) in [(true, false), (true, true), (false, false), (false, true)] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width":4,"height":4,"background":"#804020"})).unwrap();
|
||||
app.run("actions.setHistory", json!({"singleState":single_state})).unwrap();
|
||||
let mut action =
|
||||
Action::new("Bound", vec![("layer.renameLayer".into(), json!({"name":"Prefix"})), ("filter.blur.gaussianBlur".into(), json!({"radius":1}))]);
|
||||
action.dialogs.insert(1);
|
||||
app.session.actions.list.push(action);
|
||||
let paused = play(&mut app, &json!({"action":0})).unwrap();
|
||||
let dialog = paused["paused"]["dialog"].as_u64().unwrap();
|
||||
if switch {
|
||||
app.session.execute("file.new", json!({"width":2,"height":2})).unwrap();
|
||||
} else {
|
||||
app.session.execute("layer.renameLayer", json!({"name":"Unrelated"})).unwrap();
|
||||
}
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let history = app.session.active().unwrap().history.entries();
|
||||
assert!(crate::dialogs::confirm(&mut app, dialog).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &before));
|
||||
assert_eq!(app.session.active().unwrap().history.entries(), history);
|
||||
assert!(app.filter_preview.is_none());
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
assert_eq!(app.session.actions.playing, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare a real paused spelling step with a known document and history boundary, under either grouping policy.
|
||||
fn paused_history_spelling(single: bool, gate: Option<Gate>) -> (PhotocraftApp, std::sync::Arc<photocraft_doc::Document>, usize) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width":300,"height":100})).unwrap();
|
||||
app.run("type.create", json!({"x":10,"y":50,"text":"Teh brwon"})).unwrap();
|
||||
app.run("actions.setHistory", json!({"singleState":single})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let past = app.session.active().unwrap().history.past_len();
|
||||
let mut action = Action::new(
|
||||
"Spell",
|
||||
vec![
|
||||
("layer.renameLayer".into(), json!({"name":"Prefix"})),
|
||||
("edit.checkSpelling".into(), json!({"allLayers":false})),
|
||||
("layer.renameLayer".into(), json!({"name":"Tail"})),
|
||||
],
|
||||
);
|
||||
action.dialogs.insert(1);
|
||||
app.session.actions.list.push(action);
|
||||
app.session.authorize = gate;
|
||||
let paused = play(&mut app, &json!({"action":"Spell"})).unwrap();
|
||||
assert_eq!(paused["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{paused}");
|
||||
(app, before, past)
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Repeated corrections share one owned history entry; a rejected correction cannot lose earlier admitted edits.
|
||||
fn paused_spelling_repeated_changes_and_errors_keep_one_owned_history() {
|
||||
for single in [false, true] {
|
||||
let (mut app, before, past) = paused_history_spelling(single, None);
|
||||
let paused_doc = app.session.active().unwrap().doc.clone();
|
||||
let rows = app.session.active().unwrap().history.entries();
|
||||
assert!(spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"wrong","replace":"The"})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &paused_doc));
|
||||
assert_eq!(app.session.active().unwrap().history.entries(), rows);
|
||||
for params in
|
||||
[json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"}), json!({"action":"changeAll","word":"brwon","replace":"brown"})]
|
||||
{
|
||||
spelling_change(&mut app, params).unwrap();
|
||||
let token = app.ui.type_panels.spell.as_ref().unwrap().resume.unwrap();
|
||||
assert!(app.paused_playbacks.paused[&token].history.is_some());
|
||||
assert_eq!(app.session.actions.playing, 0);
|
||||
}
|
||||
let st = app.session.active().unwrap();
|
||||
let texts: Vec<_> = st
|
||||
.doc
|
||||
.walk()
|
||||
.into_iter()
|
||||
.filter_map(|(_, _, layer)| match &layer.content {
|
||||
photocraft_doc::LayerContent::Text(text) => Some(text.text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(texts, ["The brown"]);
|
||||
let before_tail = st.doc.clone();
|
||||
crate::type_panels_ui::close_spelling(&mut app);
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.history.past_len(), past + if single { 1 } else { 4 });
|
||||
assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "Tail");
|
||||
if single {
|
||||
assert_eq!(st.history.undo_label(), Some("Action: Spell"));
|
||||
}
|
||||
let after = st.doc.clone();
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), if single { before.as_ref() } else { before_tail.as_ref() });
|
||||
assert!(app.session.redo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
app.run("layer.renameLayer", json!({"name":"Outside"})).unwrap();
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
fn deny_second_spelling_change(id: &str, params: &Value) -> photocraft_engine::Result<()> {
|
||||
if id == "edit.checkSpelling" && params["word"] == "brwon" { Err(photocraft_engine::EngineError::Other("original spelling gate".into())) } else { Ok(()) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Reject a spelling continuation after document/history drift or captured-policy denial before any correction.
|
||||
fn paused_spelling_rejects_external_edit_switch_and_original_gate_before_change() {
|
||||
for single in [false, true] {
|
||||
for scenario in ["edit", "switch", "gate"] {
|
||||
let gate = (scenario == "gate").then_some(deny_second_spelling_change as Gate);
|
||||
let (mut app, _, _) = paused_history_spelling(single, gate);
|
||||
spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap();
|
||||
let corrected = app.session.active().unwrap().doc.clone();
|
||||
if scenario == "edit" {
|
||||
let past = app.session.active().unwrap().history.past_len();
|
||||
app.run("layer.renameLayer", json!({"name":"External"})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), past + 1, "idle edits have their own history");
|
||||
} else if scenario == "switch" {
|
||||
app.run("file.new", json!({"width":4,"height":4})).unwrap();
|
||||
}
|
||||
// The original playback gate still applies even if the live gate has gone away.
|
||||
app.session.authorize = None;
|
||||
let before: Vec<_> = app.session.documents().iter().map(|st| (st.doc.clone(), st.history.entries())).collect();
|
||||
let error = spelling_change(&mut app, json!({"action":"changeAll","word":"brwon","replace":"brown"})).unwrap_err();
|
||||
if scenario == "gate" {
|
||||
assert!(error.contains("original spelling gate"), "{error}");
|
||||
}
|
||||
for (state, (doc, rows)) in app.session.documents().iter().zip(&before) {
|
||||
assert!(std::sync::Arc::ptr_eq(&state.doc, doc), "{scenario}");
|
||||
assert_eq!(&state.history.entries(), rows);
|
||||
}
|
||||
crate::type_panels_ui::close_spelling(&mut app);
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
assert_eq!(app.session.actions.playing, 0);
|
||||
if scenario == "gate" {
|
||||
assert_eq!(app.session.active().unwrap().doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap().name, "Tail");
|
||||
} else {
|
||||
assert!(!app.session.documents().iter().any(|st| st.doc.walk().iter().any(|(_, _, layer)| layer.name == "Tail")));
|
||||
assert!(app.ui.status_error, "{}", app.ui.status);
|
||||
if scenario == "edit" {
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), corrected.as_ref());
|
||||
} else {
|
||||
assert!(app.session.documents().iter().any(|st| std::sync::Arc::ptr_eq(&st.doc, &corrected)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Browser multi-selection must not be truncated to the first file.
|
||||
fn bulk_load_imports_every_chosen_action_file() {
|
||||
@@ -1112,6 +1320,8 @@ fn a_set_paused_inside_a_called_action_stops_there() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let count = app.session.active().unwrap().history.past_len();
|
||||
let mut a = Action::new("A", vec![("actions.play".into(), json!({"action": "C"})), ("layer.new.layer".into(), json!({"name": "a-tail"}))]);
|
||||
a.set = Some("S".into());
|
||||
let mut b = Action::new("B", vec![("layer.new.layer".into(), json!({"name": "b"}))]);
|
||||
@@ -1129,6 +1339,13 @@ fn a_set_paused_inside_a_called_action_stops_there() {
|
||||
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["c"], "C finished; B didn't run ahead of A's tail");
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), count + 1);
|
||||
assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Action: A"));
|
||||
let after = h.state().session.active().unwrap().doc.clone();
|
||||
assert!(h.state_mut().session.undo());
|
||||
assert_eq!(h.state().session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert!(h.state_mut().session.redo());
|
||||
assert_eq!(h.state().session.active().unwrap().doc.as_ref(), after.as_ref());
|
||||
}
|
||||
|
||||
/// #10459 fix-up: a set folder activated without a pointer (keyboard, accessibility) still
|
||||
@@ -2029,6 +2246,465 @@ fn deny_blur(id: &str, _: &Value) -> photocraft_engine::Result<()> {
|
||||
if id == "filter.blur.gaussianBlur" { Err(photocraft_engine::EngineError::Other("blur denied".into())) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Permit the recorded small blur but reject an edited radius, so admission is tested against final dialog parameters.
|
||||
fn deny_wide_blur(id: &str, params: &Value) -> photocraft_engine::Result<()> {
|
||||
if id == "filter.blur.gaussianBlur" && params["radius"].as_f64().is_some_and(|radius| radius > 1.0) {
|
||||
Err(photocraft_engine::EngineError::Other("edited blur radius denied".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a two-tone raster whose blur changes pixels, with a real action step configured to open its dialog.
|
||||
fn gated_blur_fixture(single: bool, background: bool) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 16, "height": 16, "background": "#000000"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 8, "y": 0, "width": 8, "height": 16})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ffffff"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
app.run("actions.setHistory", json!({"singleState": single})).unwrap();
|
||||
app.background_jobs = background;
|
||||
let mut action = Action::new("Gated blur", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1}))]);
|
||||
action.dialogs.insert(0);
|
||||
app.session.actions.list.push(action);
|
||||
app
|
||||
}
|
||||
|
||||
/// Simulate a temporary control gate: capture it at the pause and release the session gate before later confirmation.
|
||||
fn pause_gated_blur(app: &mut PhotocraftApp, gate: Option<Gate>) -> u64 {
|
||||
app.session.authorize = gate;
|
||||
let paused = play(app, &json!({"action": "Gated blur"})).unwrap();
|
||||
app.session.authorize = None; // The temporary control gate ends before a later human OK.
|
||||
paused["paused"]["dialog"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Denied edited parameters must change neither pixels nor history and must admit no background worker.
|
||||
fn a_paused_dialog_authorizes_edited_params_before_history_or_worker_admission() {
|
||||
for (single, background) in [(true, false), (true, true), (false, false)] {
|
||||
let mut app = gated_blur_fixture(single, background);
|
||||
let dialog = pause_gated_blur(&mut app, Some(deny_wide_blur));
|
||||
app.ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!(4));
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let count = app.session.active().unwrap().history.past_len();
|
||||
let error = crate::dialogs::confirm(&mut app, dialog).unwrap_err();
|
||||
assert!(error.contains("edited blur radius denied"), "{error}");
|
||||
let state = app.session.active().unwrap();
|
||||
assert_eq!(state.doc.as_ref(), before.as_ref(), "denial changes no pixels");
|
||||
assert_eq!(state.revision, revision);
|
||||
assert_eq!(state.history.past_len(), count);
|
||||
assert!(app.session.job_on(state.doc.id).is_none(), "no worker was admitted");
|
||||
assert!(app.session.authorize.is_none());
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
assert_eq!(app.session.actions.playing, 0);
|
||||
app.run("layer.renameLayer", json!({"name": "Outside"})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), count + 1, "no action scope leaked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Allowed captured edits and unrelated manual dialogs retain their normal synchronous and worker behavior.
|
||||
fn an_allowed_paused_edit_and_an_ordinary_manual_dialog_still_run() {
|
||||
for (paused, background) in [(true, false), (true, true), (false, false)] {
|
||||
let mut app = gated_blur_fixture(true, background);
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let dialog = if paused {
|
||||
pause_gated_blur(&mut app, Some(deny_wide_blur))
|
||||
} else {
|
||||
crate::dialogs::open_command_dialog(&mut app, "filter.blur.gaussianBlur", "Gaussian Blur")
|
||||
};
|
||||
app.ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!(if paused { 1 } else { 4 }));
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
let state = app.session.active().unwrap();
|
||||
assert_ne!(state.doc.layers[0].surface().unwrap().pixel(7, 8), before.layers[0].surface().unwrap().pixel(7, 8));
|
||||
assert!(app.session.job_on(state.doc.id).is_none(), "the resumed action waited for its worker");
|
||||
assert!(app.session.authorize.is_none(), "the transient captured gate was restored");
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
assert_eq!(app.session.actions.playing, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A paused action's captured permission cannot bypass a stricter live policy on confirmation.
|
||||
fn a_stricter_current_gate_still_denies_the_captured_dialog_edit() {
|
||||
for service in [false, true] {
|
||||
let mut app = gated_blur_fixture(true, true);
|
||||
let dialog = pause_gated_blur(&mut app, Some(deny_wide_blur));
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
if service {
|
||||
app.automation_input = true;
|
||||
app.services.automation_authorize = Some(deny_blur);
|
||||
} else {
|
||||
app.session.authorize = Some(deny_blur);
|
||||
}
|
||||
let error = crate::dialogs::confirm(&mut app, dialog).unwrap_err();
|
||||
assert!(error.contains("blur denied"), "{error}");
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
if service {
|
||||
assert!(app.session.authorize.is_none());
|
||||
} else {
|
||||
assert!(app.session.authorize.unwrap()("filter.blur.gaussianBlur", &json!({"radius": 1})).is_err(), "the live gate was restored");
|
||||
}
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Spelling corrections consult live Session and automation-service policies before resuming the owned history.
|
||||
fn paused_spelling_checks_the_live_gate_before_resuming_history() {
|
||||
fn deny_correction(id: &str, p: &Value) -> photocraft_engine::Result<()> {
|
||||
if id == "edit.checkSpelling" && p["action"] == "changeAll" { Err(photocraft_engine::EngineError::Other("live spelling gate".into())) } else { Ok(()) }
|
||||
}
|
||||
for service in 0..3 {
|
||||
let (mut app, _, _) = paused_history_spelling(true, Some(deny_second_spelling_change));
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let rows = app.session.active().unwrap().history.entries();
|
||||
let token = app.ui.type_panels.spell.as_ref().unwrap().resume.unwrap();
|
||||
if service != 1 {
|
||||
app.session.authorize = None;
|
||||
}
|
||||
match service {
|
||||
0 => app.session.authorize = Some(deny_correction),
|
||||
1 => {
|
||||
app.automation_input = true;
|
||||
app.services.automation_authorize = Some(deny_correction);
|
||||
}
|
||||
_ => {
|
||||
app.automation_input = true;
|
||||
app.services.automation_command = Some(Box::new(|id, _| if id == "edit.checkSpelling" { Err("live spelling gate".into()) } else { Ok(()) }));
|
||||
}
|
||||
}
|
||||
assert!(spelling_change(&mut app, json!({"action":"changeAll","word":"Teh","replace":"The"})).is_err());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert_eq!(app.session.active().unwrap().history.entries(), rows);
|
||||
assert!(app.paused_playbacks.paused.get(&token).unwrap().history.is_some(), "denial must leave the bound continuation untouched");
|
||||
assert!(app.session.execute("actions.setHistory", json!({"singleState":false})).is_err());
|
||||
let fields = serde_json::Map::from_iter([(RESUME.to_string(), json!(token))]);
|
||||
abandoned(&mut app, &fields);
|
||||
app.session.execute("actions.setHistory", json!({"singleState":false})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A changed authority context cancels both direct and nested tails while keeping the admitted prefix undoable.
|
||||
fn a_changed_live_gate_cannot_be_replaced_for_the_remaining_action() {
|
||||
fn deny_tail(id: &str, _: &Value) -> photocraft_engine::Result<()> {
|
||||
if id == "layer.renameLayer" { Err(photocraft_engine::EngineError::Other("live tail denied".into())) } else { Ok(()) }
|
||||
}
|
||||
for service in [false, true] {
|
||||
for nested in [false, true] {
|
||||
let (mut app, before, past) = paused_history_spelling(true, Some(deny_wide_blur));
|
||||
if nested {
|
||||
app.session.actions.list.push(Action::new("Nested tail", vec![("layer.renameLayer".into(), json!({"name":"Forbidden tail"}))]));
|
||||
app.session.actions.list[0].steps[2] = ("actions.play".into(), json!({"action":"Nested tail"}));
|
||||
}
|
||||
spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap();
|
||||
let corrected = app.session.active().unwrap().doc.clone();
|
||||
if service {
|
||||
app.automation_input = true;
|
||||
app.services.automation_authorize = Some(deny_tail);
|
||||
} else {
|
||||
app.session.authorize = Some(deny_tail);
|
||||
}
|
||||
crate::type_panels_ui::close_spelling(&mut app);
|
||||
assert!(app.ui.status_error && app.ui.status.contains("authorization context changed"), "{}", app.ui.status);
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), corrected.as_ref(), "the admitted prefix remains, with no tail edit");
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), past + 1);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
assert_eq!(app.session.actions.playing, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Distinct captured/live gates fail before applying the dialog or starting a worker, even if the edit is allowed.
|
||||
fn a_changed_context_is_rejected_before_dialog_or_worker_admission() {
|
||||
fn allow_all(_: &str, _: &Value) -> photocraft_engine::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
for background in [false, true] {
|
||||
let mut app = gated_blur_fixture(true, background);
|
||||
let dialog = pause_gated_blur(&mut app, Some(deny_wide_blur));
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let rows = app.session.active().unwrap().history.entries();
|
||||
app.session.authorize = Some(allow_all);
|
||||
assert!(crate::dialogs::confirm(&mut app, dialog).is_err_and(|e| e.contains("authorization context changed")));
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert_eq!(app.session.active().unwrap().history.entries(), rows);
|
||||
assert!(app.session.job_on(before.id).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The same gate, a released temporary gate and a newly installed gate permit supported continuation paths.
|
||||
fn unchanged_and_released_gates_allow_the_action_tail() {
|
||||
for scenario in ["released", "same", "new"] {
|
||||
let mut app = gated_blur_fixture(true, false);
|
||||
app.session.actions.list[0].steps.push(("layer.renameLayer".into(), json!({"name":"Allowed tail"})));
|
||||
let dialog = pause_gated_blur(&mut app, (scenario != "new").then_some(deny_wide_blur as Gate));
|
||||
match scenario {
|
||||
"same" | "new" => app.session.authorize = Some(deny_wide_blur),
|
||||
_ => {}
|
||||
}
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
let state = app.session.active().unwrap();
|
||||
assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().name, "Allowed tail");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Distinct configured policies permit manual confirmation but cannot be combined for automated dispatch.
|
||||
/// The identical-gate automated control proves the supported composed-policy path still completes and undoes once.
|
||||
fn configured_gates_require_one_composed_policy_for_automated_resume() {
|
||||
fn deny_file_open(id: &str, _: &Value) -> photocraft_engine::Result<()> {
|
||||
if id == "file.open" { Err(photocraft_engine::EngineError::Other("file open denied".into())) } else { Ok(()) }
|
||||
}
|
||||
assert!(!std::ptr::fn_addr_eq(deny_wide_blur as Gate, deny_file_open as Gate));
|
||||
for (automated, identical) in [(false, false), (true, false), (true, true)] {
|
||||
let mut app = gated_blur_fixture(true, false);
|
||||
app.session.actions.list[0].steps.push(("layer.renameLayer".into(), json!({"name":"Allowed tail"})));
|
||||
app.session.authorize = Some(deny_wide_blur);
|
||||
let service: Gate = if identical { deny_wide_blur } else { deny_file_open };
|
||||
app.services.automation_authorize = Some(service);
|
||||
// The service is configured before the real local pause, but becomes effective only
|
||||
// for the automated OK. Both callbacks allow this edit and its remaining command.
|
||||
for gate in [deny_wide_blur as Gate, deny_file_open as Gate] {
|
||||
gate("filter.blur.gaussianBlur", &json!({"radius":1})).unwrap();
|
||||
gate("layer.renameLayer", &json!({"name":"Allowed tail"})).unwrap();
|
||||
}
|
||||
let dialog = play(&mut app, &json!({"action":"Gated blur"})).unwrap()["paused"]["dialog"].as_u64().unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let rows = app.session.active().unwrap().history.past_len();
|
||||
app.automation_input = automated;
|
||||
let result = crate::dialogs::confirm(&mut app, dialog);
|
||||
let state = app.session.active().unwrap();
|
||||
if automated && !identical {
|
||||
assert!(result.is_err_and(|error| error.contains("policies that cannot be combined")));
|
||||
assert_eq!(state.doc.as_ref(), before.as_ref());
|
||||
assert_eq!(state.history.past_len(), rows);
|
||||
assert!(app.session.job_on(before.id).is_none());
|
||||
} else {
|
||||
result.unwrap();
|
||||
assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().name, "Allowed tail");
|
||||
assert_eq!(state.history.past_len(), rows + 1);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
}
|
||||
assert!(std::ptr::fn_addr_eq(app.session.authorize.unwrap(), deny_wide_blur as Gate));
|
||||
assert!(std::ptr::fn_addr_eq(app.services.automation_authorize.unwrap(), service));
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
assert_eq!(app.session.actions.playing, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A policy change after the final spelling step must not discard completed edits when no tail remains to dispatch.
|
||||
fn a_changed_gate_after_the_last_spelling_step_keeps_completed_edits() {
|
||||
let (mut app, before, past) = paused_history_spelling(true, Some(deny_wide_blur));
|
||||
app.session.actions.list[0].steps.truncate(2);
|
||||
spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap();
|
||||
let corrected = app.session.active().unwrap().doc.clone();
|
||||
app.session.authorize = Some(|_, _| Ok(()));
|
||||
crate::type_panels_ui::close_spelling(&mut app);
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), corrected.as_ref());
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), past + 1);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Implicit layer-owned adjustments reject a receiver switch; explicit receivers change only their intended surface.
|
||||
/// An omitted target separately inherits the mask captured when its dialog opens.
|
||||
fn paused_adjustments_bind_implicit_pixel_and_mask_receivers() {
|
||||
for targeting in ["implicit", "null", "explicit"] {
|
||||
for mask in [false, true] {
|
||||
let mut app = gated_blur_fixture(true, false);
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
|
||||
app.run("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#808080","target":"mask"})).unwrap();
|
||||
let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap());
|
||||
// Native mask eligibility checks the active layer, so keep both receivers eligible.
|
||||
// The assertions below still require the explicitly named A mask alone to change.
|
||||
app.run("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
app.session.select_layer(a).unwrap();
|
||||
let mut params = json!({"brightness":40,"target":"pixels"});
|
||||
if mask {
|
||||
params["target"] = json!("mask");
|
||||
}
|
||||
if targeting == "explicit" {
|
||||
params["layer"] = json!(a.0);
|
||||
} else if targeting == "null" {
|
||||
params["layer"] = Value::Null;
|
||||
}
|
||||
app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), params);
|
||||
let dialog = pause_gated_blur(&mut app, None);
|
||||
app.session.select_layer(b).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let result = crate::dialogs::confirm(&mut app, dialog);
|
||||
if targeting == "explicit" {
|
||||
result.unwrap();
|
||||
let after = &app.session.active().unwrap().doc;
|
||||
assert_eq!(after.layer(b).unwrap(), before.layer(b).unwrap());
|
||||
if mask {
|
||||
assert_ne!(after.layer(a).unwrap().mask, before.layer(a).unwrap().mask);
|
||||
assert_eq!(after.layer(a).unwrap().surface(), before.layer(a).unwrap().surface());
|
||||
} else {
|
||||
assert_ne!(after.layer(a).unwrap().surface(), before.layer(a).unwrap().surface());
|
||||
assert_eq!(after.layer(a).unwrap().mask, before.layer(a).unwrap().mask);
|
||||
}
|
||||
} else {
|
||||
assert!(result.is_err_and(|e| e.contains("receiver changed")));
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch 16 review: a dialog paused on a document target (an alpha channel) and switched to
|
||||
// the layer's pixels checks the layer that was active at the pause.
|
||||
{
|
||||
let mut app = gated_blur_fixture(true, false);
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap());
|
||||
app.session.select_layer(a).unwrap();
|
||||
app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), json!({"brightness":40,"target":{"channel":0}}));
|
||||
let dialog = pause_gated_blur(&mut app, None);
|
||||
app.ui.dialog_mut(dialog).unwrap().fields.insert("target".into(), json!("pixels"));
|
||||
app.session.select_layer(b).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let result = crate::dialogs::confirm(&mut app, dialog);
|
||||
assert!(result.is_err_and(|e| e.contains("receiver changed")));
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
}
|
||||
|
||||
// An omitted target inherits the mask selected when the paused dialog opens. This is
|
||||
// distinct from native playback without a dialog, which resolves the current channel.
|
||||
let mut app = gated_blur_fixture(true, false);
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.run("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#808080","target":"mask"})).unwrap();
|
||||
assert!(app.layer_mask_targeted());
|
||||
app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), json!({"brightness":40}));
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let rows = app.session.active().unwrap().history.past_len();
|
||||
let dialog = pause_gated_blur(&mut app, None);
|
||||
assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["target"], "mask");
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
let state = app.session.active().unwrap();
|
||||
assert_ne!(state.doc.layer(a).unwrap().mask, before.layer(a).unwrap().mask);
|
||||
assert_eq!(state.doc.layer(a).unwrap().surface(), before.layer(a).unwrap().surface());
|
||||
assert_eq!(state.history.past_len(), rows + 1);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Document-owned alpha channels remain valid targets when the active layer changes during the pause.
|
||||
fn a_document_channel_adjustment_survives_an_active_layer_change() {
|
||||
let mut app = gated_blur_fixture(true, false);
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap());
|
||||
app.run("channel.new", json!({"name":"Alpha","fill":"black"})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#808080","target":{"channel":0}})).unwrap();
|
||||
app.session.select_layer(a).unwrap();
|
||||
app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), json!({"brightness":40,"target":{"channel":0}}));
|
||||
let dialog = pause_gated_blur(&mut app, None);
|
||||
app.session.select_layer(b).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
let after = &app.session.active().unwrap().doc;
|
||||
assert_eq!(after.layers, before.layers);
|
||||
assert_ne!(after.channels, before.channels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A public Session layer switch invalidates an implicit receiver while explicit-layer playback remains usable.
|
||||
fn an_implicit_paused_filter_refuses_a_public_api_receiver_change() {
|
||||
for single in [false, true] {
|
||||
for explicit in [false, true] {
|
||||
let mut app = gated_blur_fixture(single, false);
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap());
|
||||
app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
app.session.select_layer(a).unwrap();
|
||||
if explicit {
|
||||
app.session.actions.list[0].steps[0].1["layer"] = json!(a.0);
|
||||
}
|
||||
let dialog = pause_gated_blur(&mut app, None);
|
||||
// Supported host/session API, not a modal-blocked native menu/control command.
|
||||
app.session.select_layer(b).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let result = crate::dialogs::confirm(&mut app, dialog);
|
||||
let after = &app.session.active().unwrap().doc;
|
||||
if explicit {
|
||||
result.unwrap();
|
||||
assert_ne!(after.layer(a).unwrap().surface().unwrap().pixel(7, 8), before.layer(a).unwrap().surface().unwrap().pixel(7, 8));
|
||||
assert_eq!(after.layer(b).unwrap().surface().unwrap().pixel(7, 8), before.layer(b).unwrap().surface().unwrap().pixel(7, 8));
|
||||
} else {
|
||||
assert!(result.is_err_and(|e| e.contains("receiver changed")));
|
||||
assert_eq!(after.as_ref(), before.as_ref(), "neither layer may be edited after stale implicit targeting");
|
||||
}
|
||||
assert!(app.paused_playbacks.paused.is_empty());
|
||||
assert_eq!(app.session.actions.playing, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Changes to other selected layers do not invalidate the unchanged active receiver captured by a filter dialog.
|
||||
fn an_implicit_filter_keeps_its_receiver_when_only_other_selected_layers_change() {
|
||||
let mut app = gated_blur_fixture(true, false);
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().unwrap());
|
||||
app.session.select_layer(a).unwrap();
|
||||
let dialog = pause_gated_blur(&mut app, None);
|
||||
app.session.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().selected_layers().len(), 2);
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
let after = &app.session.active().unwrap().doc;
|
||||
assert_ne!(after.layer(a).unwrap().surface().unwrap().pixel(7, 8), before.layer(a).unwrap().surface().unwrap().pixel(7, 8));
|
||||
assert_eq!(after.layer(b).unwrap().surface().unwrap().content_bounds(), before.layer(b).unwrap().surface().unwrap().content_bounds());
|
||||
}
|
||||
|
||||
fn deny_displace_file(id: &str, params: &Value) -> photocraft_engine::Result<()> {
|
||||
if id == "filter.distort.displace" && params.get("mapPath").is_some() {
|
||||
Err(photocraft_engine::EngineError::Other("chosen displacement map denied".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Authorize a deferred displacement-map path before file access, worker admission or continuation history changes.
|
||||
fn a_deferred_displace_map_is_authorized_before_reading_or_resuming_history() {
|
||||
let mut app = recording_with_displace_child();
|
||||
app.background_jobs = true;
|
||||
let (show, _) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::Paths(vec!["denied-map.png".into()]))]);
|
||||
app.services.file_dialog = Some(show);
|
||||
app.session.authorize = Some(deny_displace_file);
|
||||
let dialog = pause_child(&mut app);
|
||||
app.session.authorize = None;
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let count = app.session.active().unwrap().history.past_len();
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
assert!(app.file_dialog_open());
|
||||
app.poll_file_dialog(&egui::Context::default(), None);
|
||||
assert!(app.ui.status.contains("chosen displacement map denied"), "{}", app.ui.status);
|
||||
assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref());
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), count);
|
||||
assert!(app.session.authorize.is_none());
|
||||
assert!(app.session.job_on(before.id).is_none());
|
||||
assert_displace_call_finished(&mut app, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_the_gate_denies_opens_no_dialog() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
//! The Layers panel's filter row (#5048): a filter-type dropdown, as in Photoshop, then the
|
||||
//! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Effect
|
||||
//! picks one effect kind (#10880), Mode picks one blend mode, Color one layer colour label (#5898),
|
||||
//! Smart Object toggles linked, missing and embedded smart objects, Artboard lists only the
|
||||
//! artboards that hold the selected layers. The criteria live in `UiState` (`layer_filter*`), so
|
||||
//! the control channel can read and set them.
|
||||
//! picks one effect kind (#10880), Mode picks one blend mode, Attribute one layer attribute or
|
||||
//! its opposite (#10881), Color one layer colour label (#5898), Smart Object toggles linked,
|
||||
//! missing and embedded smart objects, Artboard lists only the artboards that hold the selected
|
||||
//! layers. The criteria live in `UiState` (`layer_filter*`), so the control channel can read and
|
||||
//! set them.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use photocraft_color::BlendMode;
|
||||
use photocraft_doc::{Document, LabelColor, Layer, LayerContent, LayerId, SmartSource};
|
||||
use photocraft_doc::{Document, Fill, LabelColor, Layer, LayerContent, LayerId, SmartSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::UiState;
|
||||
@@ -23,6 +24,7 @@ pub enum FilterBy {
|
||||
Name,
|
||||
Effect,
|
||||
Mode,
|
||||
Attribute,
|
||||
Color,
|
||||
SmartObject,
|
||||
Artboard,
|
||||
@@ -37,6 +39,7 @@ pub struct Filter {
|
||||
pub name: String,
|
||||
pub mode: BlendMode,
|
||||
pub effect: effect::EffectKind,
|
||||
pub attribute: attribute::Attribute,
|
||||
pub color: LabelColor,
|
||||
/// The Smart Object toggles that are on, from [`SMART`].
|
||||
pub smart: Vec<String>,
|
||||
@@ -45,6 +48,9 @@ pub struct Filter {
|
||||
pub missing: HashSet<String>,
|
||||
/// The rows the Artboard filter lists ([`board_rows`]); empty lists every row.
|
||||
pub board_rows: HashSet<LayerId>,
|
||||
/// The empty layers the Attribute filter's Empty and Not Empty use
|
||||
/// ([`attribute::empty_layers`]); `None` reads each layer's pixels.
|
||||
pub empty: Option<std::sync::Arc<HashSet<LayerId>>>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
@@ -55,16 +61,19 @@ impl Filter {
|
||||
name: ui.layer_filter_name.clone(),
|
||||
mode: ui.layer_filter_mode,
|
||||
effect: ui.layer_filter_effect,
|
||||
attribute: ui.layer_filter_attribute,
|
||||
color: ui.layer_filter_color,
|
||||
smart: ui.layer_filter_smart.clone(),
|
||||
missing: HashSet::new(),
|
||||
board_rows: HashSet::new(),
|
||||
empty: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns [`Filter::of`] for `app`'s settings, with the active document's missing linked
|
||||
/// files while the panel filters by Smart Object, and `board_rows` set to the rows of the
|
||||
/// artboards that hold the active document's selected layers while it filters by Artboard.
|
||||
/// artboards that hold the active document's selected layers while it filters by Artboard,
|
||||
/// and `empty` set while it filters by Empty or Not Empty.
|
||||
pub fn of_app(app: &PhotocraftApp) -> Self {
|
||||
let mut f = Self::of(&app.ui);
|
||||
if f.by == FilterBy::SmartObject {
|
||||
@@ -75,18 +84,21 @@ impl Filter {
|
||||
{
|
||||
f.board_rows = board_rows(&d.doc, &d.selected_layers());
|
||||
}
|
||||
if f.by == FilterBy::Attribute {
|
||||
f.empty = attribute::empty_layers(app, f.attribute);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Whether the panel is filtered. Like Photoshop, Effect, Mode and Color always filter (by
|
||||
/// Bevel & Emboss, by Normal and by no colour at first); Kind, Name and Smart Object only once a
|
||||
/// toggle is on or some text is typed; Artboard only while some layers are selected and each of
|
||||
/// them is in an artboard.
|
||||
/// Whether the panel is filtered. Like Photoshop, Effect, Mode, Attribute and Color always
|
||||
/// filter (by Bevel & Emboss, by Normal, by Visible and by no colour at first); Kind, Name and
|
||||
/// Smart Object only once a toggle is on or some text is typed; Artboard only while some layers
|
||||
/// are selected and each of them is in an artboard.
|
||||
pub fn active(&self) -> bool {
|
||||
match self.by {
|
||||
FilterBy::Kind => !self.kinds.is_empty(),
|
||||
FilterBy::Name => !self.name.trim().is_empty(),
|
||||
FilterBy::Effect | FilterBy::Mode | FilterBy::Color => true,
|
||||
FilterBy::Effect | FilterBy::Mode | FilterBy::Attribute | FilterBy::Color => true,
|
||||
FilterBy::SmartObject => !self.smart.is_empty(),
|
||||
FilterBy::Artboard => !self.board_rows.is_empty(),
|
||||
}
|
||||
@@ -102,6 +114,7 @@ impl Filter {
|
||||
FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()),
|
||||
FilterBy::Effect => self.effect.shows(l),
|
||||
FilterBy::Mode => l.blend == self.mode,
|
||||
FilterBy::Attribute => self.attribute.shows(l, self.empty.as_deref()),
|
||||
FilterBy::Color => l.label == self.color,
|
||||
FilterBy::SmartObject => {
|
||||
let LayerContent::Smart(sm) = &l.content else { return false };
|
||||
@@ -157,21 +170,23 @@ pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"];
|
||||
/// not there, and embedded in the document.
|
||||
pub const SMART: [&str; 3] = ["linked", "missing", "embedded"];
|
||||
|
||||
/// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, color?, smart?}}`: `current` with
|
||||
/// the given fields replaced, or an error naming the bad field (nothing changes then). `by` is
|
||||
/// `kind`, `name`, `effect`, `mode`, `color`, `smartObject` or `artboard`; `kinds` a list of
|
||||
/// [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` a blend mode name such as `Multiply`
|
||||
/// or `Pass Through`; `color` a label colour id (`none`, `red`, … `gray`, [`LabelColor::id`]);
|
||||
/// `smart` a list of [`SMART`]. Artboard has no criteria of its own: the selected layers pick the
|
||||
/// artboards.
|
||||
/// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}}`:
|
||||
/// `current` with the given fields replaced, or an error naming the bad field (nothing changes
|
||||
/// then). `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or
|
||||
/// `artboard`; `kinds` a list of [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` one of
|
||||
/// the layer blend modes such as `Multiply` (not Pass Through, which the Mode list doesn't offer,
|
||||
/// #10878); `attribute` an [`attribute::Attribute`] id; `color` a label colour id (`none`, `red`,
|
||||
/// … `gray`, [`LabelColor::id`]); `smart` a list of [`SMART`]. Artboard has no criteria of its
|
||||
/// own: the selected layers pick the artboards.
|
||||
pub fn patched(current: &UiState, v: &serde_json::Value) -> Result<Filter, String> {
|
||||
let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) };
|
||||
if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "color", "smart"].contains(&k.as_str())) {
|
||||
return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, effect, mode, color, smart)"));
|
||||
if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "attribute", "color", "smart"].contains(&k.as_str())) {
|
||||
return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, effect, mode, attribute, color, smart)"));
|
||||
}
|
||||
let mut f = Filter::of(current);
|
||||
if let Some(by) = o.get("by") {
|
||||
f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name, effect, mode, color, smartObject or artboard".to_string())?;
|
||||
f.by = serde_json::from_value(by.clone())
|
||||
.map_err(|_| "layerFilter.by must be kind, name, effect, mode, attribute, color, smartObject or artboard".to_string())?;
|
||||
}
|
||||
if let Some(kinds) = o.get("kinds") {
|
||||
let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?;
|
||||
@@ -190,13 +205,17 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result<Filter, Strin
|
||||
}
|
||||
if let Some(mode) = o.get("mode") {
|
||||
let name = mode.as_str().ok_or("layerFilter.mode must be a blend mode name")?;
|
||||
f.mode = photocraft_engine::commands::blend_from_str(name)
|
||||
.filter(|m| *m == BlendMode::PassThrough || BlendMode::LAYER_MODES.contains(m))
|
||||
.ok_or_else(|| format!("layerFilter.mode: unknown blend mode `{name}`"))?;
|
||||
f.mode = photocraft_engine::commands::blend_from_str(name).filter(|m| BlendMode::LAYER_MODES.contains(m)).ok_or_else(|| {
|
||||
let modes: Vec<&str> = BlendMode::LAYER_MODES.iter().map(|m| m.label()).collect();
|
||||
format!("layerFilter.mode: `{name}` is not a layer blend mode the filter lists (modes: {})", modes.join(", "))
|
||||
})?;
|
||||
}
|
||||
if let Some(e) = o.get("effect") {
|
||||
f.effect = effect::parse(e)?;
|
||||
}
|
||||
if let Some(a) = o.get("attribute") {
|
||||
f.attribute = attribute::parse(a)?;
|
||||
}
|
||||
if let Some(color) = o.get("color") {
|
||||
let id = color.as_str().ok_or("layerFilter.color must be a label colour id")?;
|
||||
f.color = LabelColor::ALL.into_iter().find(|c| c.id() == id).ok_or_else(|| {
|
||||
@@ -226,6 +245,7 @@ pub fn apply(ui: &mut UiState, f: Filter) {
|
||||
ui.layer_filter_name = f.name;
|
||||
ui.layer_filter_mode = f.mode;
|
||||
ui.layer_filter_effect = f.effect;
|
||||
ui.layer_filter_attribute = f.attribute;
|
||||
ui.layer_filter_color = f.color;
|
||||
ui.layer_filter_smart = f.smart;
|
||||
}
|
||||
@@ -234,7 +254,11 @@ pub fn apply(ui: &mut UiState, f: Filter) {
|
||||
fn kind(l: &Layer) -> &'static str {
|
||||
match &l.content {
|
||||
LayerContent::Raster(_) => "pixel",
|
||||
LayerContent::Adjustment(_) | LayerContent::Fill(_) => "adjustment",
|
||||
LayerContent::Adjustment(_) => "adjustment",
|
||||
// A fill layer (Layer › New Fill Layer) matches no Kind button, as in the reference
|
||||
// application, except a solid colour with a vector mask, which lists as a shape (#10878).
|
||||
LayerContent::Fill(Fill::Solid(_)) if l.vector_mask.is_some() => "shape",
|
||||
LayerContent::Fill(_) => "",
|
||||
LayerContent::Text(_) => "type",
|
||||
LayerContent::Shape(_) => "shape",
|
||||
LayerContent::Smart(_) => "smart",
|
||||
@@ -251,6 +275,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
(FilterBy::Name, tl!("Name")),
|
||||
(FilterBy::Effect, tl!("Effect")),
|
||||
(FilterBy::Mode, tl!("Mode")),
|
||||
(FilterBy::Attribute, tl!("Attribute")),
|
||||
(FilterBy::Color, tl!("Color")),
|
||||
(FilterBy::SmartObject, tl!("Smart Object")),
|
||||
(FilterBy::Artboard, tl!("Artboard")),
|
||||
@@ -282,10 +307,13 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
FilterBy::Effect => effect::controls(app, ui),
|
||||
FilterBy::Mode => {
|
||||
let modes: Vec<(BlendMode, &str)> = std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect();
|
||||
// The 27 layer modes, without Pass Through: a group set to Pass Through is never
|
||||
// listed by Mode (#10878).
|
||||
let modes: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.into_iter().map(|m| (m, m.label())).collect();
|
||||
let width = ui.available_width().max(96.0);
|
||||
widgets::dropdown(ui, "layer-filter-mode", tl!("Mode"), &mut app.ui.layer_filter_mode, &modes, width);
|
||||
}
|
||||
FilterBy::Attribute => attribute::controls(app, ui),
|
||||
FilterBy::Color => {
|
||||
// Photoshop's list: None, then the label colours in the Layers panel menu's order.
|
||||
let colors: Vec<(LabelColor, &str)> =
|
||||
@@ -313,11 +341,23 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
FilterBy::Artboard => {}
|
||||
}
|
||||
});
|
||||
selection::follow(app, ui.ctx());
|
||||
}
|
||||
|
||||
pub mod effect;
|
||||
pub mod selection;
|
||||
|
||||
#[cfg(test)]
|
||||
mod effect_tests;
|
||||
|
||||
pub mod attribute;
|
||||
|
||||
#[cfg(test)]
|
||||
mod attribute_tests;
|
||||
#[cfg(test)]
|
||||
mod selection_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod kind_mode_tests;
|
||||
@@ -0,0 +1,260 @@
|
||||
//! The Layers panel's Attribute filter (#10881): lists the layers with one attribute (visible,
|
||||
//! locked, empty, linked, clipped, masks, effects, advanced blending) or without it.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex, PoisonError, Weak};
|
||||
|
||||
use photocraft_color::SampleType;
|
||||
use photocraft_doc::{AdvancedBlending, Document, Layer, LayerContent, LayerId};
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{PhotocraftApp, widgets};
|
||||
|
||||
/// The attributes the Attribute filter picks from, in Photoshop's order: the nine attributes,
|
||||
/// then their opposites.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Attribute {
|
||||
#[default]
|
||||
Visible,
|
||||
Locked,
|
||||
Empty,
|
||||
Linked,
|
||||
Clipped,
|
||||
LayerMask,
|
||||
VectorMask,
|
||||
LayerEffects,
|
||||
AdvancedBlending,
|
||||
NotVisible,
|
||||
NotLocked,
|
||||
NotEmpty,
|
||||
NotLinked,
|
||||
NotClipped,
|
||||
NoLayerMask,
|
||||
NoVectorMask,
|
||||
NoLayerEffects,
|
||||
NoAdvancedBlending,
|
||||
}
|
||||
|
||||
impl Attribute {
|
||||
pub const ALL: [Attribute; 18] = [
|
||||
Attribute::Visible,
|
||||
Attribute::Locked,
|
||||
Attribute::Empty,
|
||||
Attribute::Linked,
|
||||
Attribute::Clipped,
|
||||
Attribute::LayerMask,
|
||||
Attribute::VectorMask,
|
||||
Attribute::LayerEffects,
|
||||
Attribute::AdvancedBlending,
|
||||
Attribute::NotVisible,
|
||||
Attribute::NotLocked,
|
||||
Attribute::NotEmpty,
|
||||
Attribute::NotLinked,
|
||||
Attribute::NotClipped,
|
||||
Attribute::NoLayerMask,
|
||||
Attribute::NoVectorMask,
|
||||
Attribute::NoLayerEffects,
|
||||
Attribute::NoAdvancedBlending,
|
||||
];
|
||||
|
||||
/// The id `ui.set {layerFilter: {attribute}}` takes.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Attribute::Visible => "visible",
|
||||
Attribute::Locked => "locked",
|
||||
Attribute::Empty => "empty",
|
||||
Attribute::Linked => "linked",
|
||||
Attribute::Clipped => "clipped",
|
||||
Attribute::LayerMask => "layerMask",
|
||||
Attribute::VectorMask => "vectorMask",
|
||||
Attribute::LayerEffects => "layerEffects",
|
||||
Attribute::AdvancedBlending => "advancedBlending",
|
||||
Attribute::NotVisible => "notVisible",
|
||||
Attribute::NotLocked => "notLocked",
|
||||
Attribute::NotEmpty => "notEmpty",
|
||||
Attribute::NotLinked => "notLinked",
|
||||
Attribute::NotClipped => "notClipped",
|
||||
Attribute::NoLayerMask => "noLayerMask",
|
||||
Attribute::NoVectorMask => "noVectorMask",
|
||||
Attribute::NoLayerEffects => "noLayerEffects",
|
||||
Attribute::NoAdvancedBlending => "noAdvancedBlending",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Attribute::Visible => tl!("Visible"),
|
||||
Attribute::Locked => tl!("Locked"),
|
||||
Attribute::Empty => tl!("Empty"),
|
||||
Attribute::Linked => tl!("Linked"),
|
||||
Attribute::Clipped => tl!("Clipped"),
|
||||
Attribute::LayerMask => tl!("Layer Mask"),
|
||||
Attribute::VectorMask => tl!("Vector Mask"),
|
||||
Attribute::LayerEffects => tl!("Layer Effects"),
|
||||
Attribute::AdvancedBlending => tl!("Advanced Blending"),
|
||||
Attribute::NotVisible => tl!("Not Visible"),
|
||||
Attribute::NotLocked => tl!("Not Locked"),
|
||||
Attribute::NotEmpty => tl!("Not Empty"),
|
||||
Attribute::NotLinked => tl!("Not Linked"),
|
||||
Attribute::NotClipped => tl!("Not Clipped"),
|
||||
Attribute::NoLayerMask => tl!("No Layer Mask"),
|
||||
Attribute::NoVectorMask => tl!("No Vector Mask"),
|
||||
Attribute::NoLayerEffects => tl!("No Layer Effects"),
|
||||
Attribute::NoAdvancedBlending => tl!("No Adv. Blending"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `l` is listed under this attribute. Each "Not …"/"No …" entry lists the layers
|
||||
/// (and groups) its positive entry doesn't. `empty` is the document's empty layers
|
||||
/// ([`empty_layers_of`]); without it, Empty reads `l`'s pixels without knowing the canvas, so a
|
||||
/// layer with an opaque default pixel is never empty.
|
||||
pub fn shows(self, l: &Layer, empty: Option<&HashSet<LayerId>>) -> bool {
|
||||
match self {
|
||||
Attribute::Visible => l.visible,
|
||||
// Any lock counts, the Background's too.
|
||||
Attribute::Locked => l.locks.any(),
|
||||
// A pixel layer with no visible pixels; an empty group isn't listed.
|
||||
Attribute::Empty => empty.map_or_else(|| is_empty(l, None), |set| set.contains(&l.id)),
|
||||
Attribute::Linked => l.link_group.is_some(),
|
||||
// The clipped layer, not its base.
|
||||
Attribute::Clipped => l.clipped,
|
||||
Attribute::LayerMask => l.mask.is_some(),
|
||||
// A shape layer's outline is its vector mask.
|
||||
Attribute::VectorMask => l.vector_mask.is_some() || matches!(l.content, LayerContent::Shape(_)),
|
||||
// Also a layer whose only effect is turned off, unlike the Effect filter.
|
||||
Attribute::LayerEffects => !l.effects.items.is_empty(),
|
||||
Attribute::AdvancedBlending => advanced_blending(l),
|
||||
Attribute::NotVisible => !Attribute::Visible.shows(l, empty),
|
||||
Attribute::NotLocked => !Attribute::Locked.shows(l, empty),
|
||||
Attribute::NotEmpty => !Attribute::Empty.shows(l, empty),
|
||||
Attribute::NotLinked => !Attribute::Linked.shows(l, empty),
|
||||
Attribute::NotClipped => !Attribute::Clipped.shows(l, empty),
|
||||
Attribute::NoLayerMask => !Attribute::LayerMask.shows(l, empty),
|
||||
Attribute::NoVectorMask => !Attribute::VectorMask.shows(l, empty),
|
||||
Attribute::NoLayerEffects => !Attribute::LayerEffects.shows(l, empty),
|
||||
Attribute::NoAdvancedBlending => !Attribute::AdvancedBlending.shows(l, empty),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `l` is a pixel layer with no visible pixels; an empty group isn't listed. Stored pixels
|
||||
/// past the canvas count (they are the layer's, Reveal All brings them back); `canvas` only bounds
|
||||
/// the check for a layer whose default pixel is opaque (see [`has_visible_pixels`]).
|
||||
fn is_empty(l: &Layer, canvas: Option<Rect>) -> bool {
|
||||
matches!(&l.content, LayerContent::Raster(s) if !has_visible_pixels(s, canvas))
|
||||
}
|
||||
|
||||
/// Whether `s` has a pixel with alpha > 0. The Eraser only clears alpha, so an erased pixel keeps
|
||||
/// its colour bytes and `content_bounds` still counts it (as Reveal All's `visible_bounds` notes,
|
||||
/// #9379). A surface without an alpha channel covers everything. With an opaque default pixel,
|
||||
/// every part of `canvas` without a stored tile shows it, so only stored tiles that cover the
|
||||
/// canvas and are clear on it make the layer empty (pixels outside the canvas are left out there,
|
||||
/// as the default reaches beyond it anyway); without a `canvas` such a layer always counts.
|
||||
fn has_visible_pixels(s: &Surface, canvas: Option<Rect>) -> bool {
|
||||
let fmt = s.format();
|
||||
if !fmt.alpha {
|
||||
return true;
|
||||
}
|
||||
let clip = match (s.default_pixel().last().is_none_or(|a| *a > 0.0), canvas) {
|
||||
(false, _) => None,
|
||||
(true, Some(canvas)) if covers(s, canvas) => Some(canvas),
|
||||
(true, _) => return true,
|
||||
};
|
||||
let (n, bpp, size) = (fmt.channels(), fmt.bytes_per_pixel(), fmt.sample.bytes());
|
||||
let Some(at) = n.checked_sub(1).and_then(|i| i.checked_mul(size)).filter(|_| bpp > 0) else { return false };
|
||||
// Integer alpha is above 0 where any of its bytes is, so a block of pixels is OR-ed together
|
||||
// (which vectorises) before looking for one; only float alpha needs decoding.
|
||||
let visible = |block: &[u8]| match fmt.sample {
|
||||
SampleType::F32 => block.chunks_exact(bpp).any(|px| px.get(at..at + size).is_some_and(|a| a.try_into().is_ok_and(|b| f32::from_ne_bytes(b) > 0.0))),
|
||||
SampleType::U8 | SampleType::U16 => {
|
||||
block.chunks_exact(bpp).fold(0u8, |acc, px| px.get(at..at + size).map_or(acc, |a| a.iter().fold(acc, |acc, b| acc | b))) != 0
|
||||
}
|
||||
};
|
||||
let tile_visible = |c: &TileCoord, t: &Arc<photocraft_raster::Tile>| {
|
||||
let bytes = t.bytes();
|
||||
let r = c.rect();
|
||||
let Some(clip) = clip.filter(|clip| !clip.contains_rect(&r)) else { return bytes.chunks(bpp.saturating_mul(1024)).any(visible) };
|
||||
// Only the part of the tile on the canvas, row by row.
|
||||
let on = r.intersect(&clip);
|
||||
let row = (TILE_SIZE as usize).saturating_mul(bpp);
|
||||
let (x0, x1) = ((on.x0 - r.x0) as usize * bpp, (on.x1 - r.x0) as usize * bpp);
|
||||
(on.y0 - r.y0..on.y1 - r.y0).any(|y| bytes.get(y as usize * row + x0..y as usize * row + x1).is_some_and(visible))
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
let tiles: Vec<_> = s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).collect();
|
||||
tiles.par_iter().any(|(c, t)| tile_visible(c, t))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).any(|(c, t)| tile_visible(c, t))
|
||||
}
|
||||
|
||||
/// Whether `s` stores a tile for every part of `canvas`.
|
||||
fn covers(s: &Surface, canvas: Rect) -> bool {
|
||||
if canvas.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let (first, last) = (TileCoord::containing(canvas.x0, canvas.y0), TileCoord::containing(canvas.x1 - 1, canvas.y1 - 1));
|
||||
let needed = (i64::from(last.tx) - i64::from(first.tx) + 1) * (i64::from(last.ty) - i64::from(first.ty) + 1);
|
||||
let stored = s.tiles().filter(|(c, _)| (first.tx..=last.tx).contains(&c.tx) && (first.ty..=last.ty).contains(&c.ty)).count();
|
||||
i64::try_from(stored).is_ok_and(|stored| stored == needed)
|
||||
}
|
||||
|
||||
/// The empty pixel layers of `doc`.
|
||||
pub fn empty_layers_of(doc: &Document) -> HashSet<LayerId> {
|
||||
let canvas = Some(doc.bounds());
|
||||
doc.walk().into_iter().filter(|(_, _, l)| is_empty(l, canvas)).map(|(_, _, l)| l.id).collect()
|
||||
}
|
||||
|
||||
/// The document [`empty_layers`] last read and what it found. A `Weak` keeps the document's
|
||||
/// allocation from being reused while it is held, and makes `Arc::make_mut` copy the document on
|
||||
/// its next edit even when nothing else holds it, so the same allocation always means the same
|
||||
/// layers and pixels.
|
||||
static EMPTY: Mutex<Option<EmptyCache>> = Mutex::new(None);
|
||||
|
||||
type EmptyCache = (Weak<Document>, Arc<HashSet<LayerId>>);
|
||||
|
||||
/// The empty layers of `app`'s active document while Empty or Not Empty is picked, found once per
|
||||
/// document: a layer erased to nothing has every pixel read, too much for every repaint of the
|
||||
/// Layers panel. Keyed by the document itself rather than its revision, which also moves when only
|
||||
/// the selection does.
|
||||
pub fn empty_layers(app: &PhotocraftApp, attribute: Attribute) -> Option<Arc<HashSet<LayerId>>> {
|
||||
if !matches!(attribute, Attribute::Empty | Attribute::NotEmpty) {
|
||||
return None;
|
||||
}
|
||||
let d = app.session.active()?;
|
||||
let mut cache = EMPTY.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some((doc, set)) = cache.as_ref()
|
||||
&& std::ptr::eq(doc.as_ptr(), Arc::as_ptr(&d.doc))
|
||||
{
|
||||
return Some(set.clone());
|
||||
}
|
||||
let set = Arc::new(empty_layers_of(&d.doc));
|
||||
*cache = Some((Arc::downgrade(&d.doc), set.clone()));
|
||||
Some(set)
|
||||
}
|
||||
|
||||
/// Whether any Advanced Blending option is away from its default: a channel left out, Knockout,
|
||||
/// the four "as Group" / "Transparency Shapes" / "Hides Effects" switches, or a Blend If range.
|
||||
/// Fill opacity alone doesn't count.
|
||||
fn advanced_blending(l: &Layer) -> bool {
|
||||
l.excluded_channels != 0 || l.advanced != AdvancedBlending::default() || !l.blend_if.is_default()
|
||||
}
|
||||
|
||||
/// `layerFilter.attribute`: the attribute named by `v`, or an error listing the ids.
|
||||
pub fn parse(v: &serde_json::Value) -> Result<Attribute, String> {
|
||||
let ids = || Attribute::ALL.map(Attribute::id).join(", ");
|
||||
let name = v.as_str().ok_or_else(|| format!("layerFilter.attribute must be one of {}", ids()))?;
|
||||
Attribute::ALL.into_iter().find(|a| a.id() == name).ok_or_else(|| format!("layerFilter.attribute: unknown attribute `{name}` (attributes: {})", ids()))
|
||||
}
|
||||
|
||||
/// The Attribute filter's controls: the attribute dropdown.
|
||||
pub fn controls(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let attributes: Vec<(Attribute, &str)> = Attribute::ALL.into_iter().map(|a| (a, a.label())).collect();
|
||||
let width = ui.available_width().max(96.0);
|
||||
widgets::dropdown(ui, "layer-filter-attribute", tl!("Attribute"), &mut app.ui.layer_filter_attribute, &attributes, width);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! #10881: the Layers panel's Attribute filter, against the cases measured in Photoshop.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_doc::{BlendRange, Document, Group, Knockout, Layer, LayerContent, LayerMask, VectorMask};
|
||||
use serde_json::json;
|
||||
|
||||
use super::attribute::Attribute;
|
||||
use super::{Filter, FilterBy};
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::UiState;
|
||||
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
|
||||
/// One layer per measured case. Every pixel layer has pixels except "empty"; the adjustment, fill
|
||||
/// and shape layers come from their commands, so they carry the masks those give them.
|
||||
fn session() -> photocraft_engine::Session {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
let pixel_layers = [
|
||||
"pixels",
|
||||
"hidden",
|
||||
"position lock",
|
||||
"all locked",
|
||||
"empty",
|
||||
"linked a",
|
||||
"linked b",
|
||||
"base",
|
||||
"masked",
|
||||
"vector masked",
|
||||
"fx",
|
||||
"fx off",
|
||||
"fill 50",
|
||||
"adv channels",
|
||||
"adv knockout",
|
||||
"adv interior",
|
||||
"adv clipped",
|
||||
"adv shapes",
|
||||
"adv layer mask",
|
||||
"adv vector mask",
|
||||
"adv blend if",
|
||||
];
|
||||
for name in pixel_layers {
|
||||
s.execute("layer.new.layer", json!({"name": name})).unwrap();
|
||||
match name {
|
||||
"base" => {
|
||||
s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap();
|
||||
rename_active(&mut s, "clipped");
|
||||
s.execute("layer.createClippingMask", json!({})).unwrap();
|
||||
}
|
||||
"fx" => {
|
||||
s.execute("layer.layerStyle.dropShadow", json!({})).unwrap();
|
||||
}
|
||||
"fx off" => {
|
||||
s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "dropShadow", "params": {"enabled": false}}]})).unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap();
|
||||
rename_active(&mut s, "fill");
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
|
||||
rename_active(&mut s, "shape");
|
||||
|
||||
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let full = photocraft_geom::Rect::new(0, 0, 64, 48);
|
||||
for name in pixel_layers.into_iter().filter(|n| *n != "empty") {
|
||||
let l = layer(doc, name);
|
||||
let LayerContent::Raster(surface) = &mut l.content else { panic!("{name} is a pixel layer") };
|
||||
surface.fill_rect(full, &RED);
|
||||
}
|
||||
layer(doc, "hidden").visible = false;
|
||||
layer(doc, "position lock").locks.position = true;
|
||||
layer(doc, "all locked").locks.all = true;
|
||||
layer(doc, "linked a").link_group = Some(7);
|
||||
layer(doc, "linked b").link_group = Some(7);
|
||||
layer(doc, "masked").mask = Some(LayerMask::reveal_all());
|
||||
layer(doc, "vector masked").vector_mask = Some(VectorMask { path: Default::default(), enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||||
layer(doc, "fill 50").fill_opacity = 0.5;
|
||||
layer(doc, "adv channels").excluded_channels = 0b010;
|
||||
layer(doc, "adv knockout").advanced.knockout = Knockout::Shallow;
|
||||
layer(doc, "adv interior").advanced.blend_interior = true;
|
||||
layer(doc, "adv clipped").advanced.blend_clipped = false;
|
||||
layer(doc, "adv shapes").advanced.transparency_shapes = false;
|
||||
layer(doc, "adv layer mask").advanced.layer_mask_hides_effects = true;
|
||||
layer(doc, "adv vector mask").advanced.vector_mask_hides_effects = true;
|
||||
layer(doc, "adv blend if").blend_if.ranges = vec![[BlendRange { black: [40, 40], white: [255, 255] }, BlendRange::FULL]];
|
||||
// A visible group holding a pixel layer, and an empty group.
|
||||
let mut inside = Layer::new("inside", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8)));
|
||||
if let LayerContent::Raster(surface) = &mut inside.content {
|
||||
surface.fill_rect(full, &RED);
|
||||
}
|
||||
doc.layers.push(Layer::new("group", LayerContent::Group(Group { children: vec![inside], expanded: true, artboard: None, frame_stroke: None })));
|
||||
doc.layers.push(Layer::new("empty group", LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None, frame_stroke: None })));
|
||||
// A hidden group whose child is visible: Visible goes by the layer's own flag.
|
||||
let mut child = Layer::new("in hidden group", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8)));
|
||||
if let LayerContent::Raster(surface) = &mut child.content {
|
||||
surface.fill_rect(full, &RED);
|
||||
}
|
||||
let mut hidden_group = Layer::new("hidden group", LayerContent::Group(Group { children: vec![child], expanded: true, artboard: None, frame_stroke: None }));
|
||||
hidden_group.visible = false;
|
||||
doc.layers.push(hidden_group);
|
||||
s
|
||||
}
|
||||
|
||||
fn layer<'a>(doc: &'a mut Document, name: &str) -> &'a mut Layer {
|
||||
let id = doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).unwrap_or_else(|| panic!("no layer {name}"));
|
||||
doc.layer_mut(id).unwrap()
|
||||
}
|
||||
|
||||
fn rename_active(s: &mut photocraft_engine::Session, name: &str) {
|
||||
let d = s.active_mut().unwrap();
|
||||
let id = d.active_layer.unwrap();
|
||||
std::sync::Arc::make_mut(&mut d.doc).layer_mut(id).unwrap().name = name.into();
|
||||
}
|
||||
|
||||
/// The names `attribute` lists.
|
||||
fn listed(doc: &Document, attribute: Attribute) -> BTreeSet<String> {
|
||||
let mut filter = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, layer_filter_attribute: attribute, ..UiState::default() });
|
||||
// As the panel does ([`Filter::of_app`]): Empty knows the canvas.
|
||||
filter.empty = Some(std::sync::Arc::new(super::attribute::empty_layers_of(doc)));
|
||||
crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn names(list: &[&str]) -> BTreeSet<String> {
|
||||
list.iter().map(|n| n.to_string()).collect()
|
||||
}
|
||||
|
||||
fn all(doc: &Document) -> BTreeSet<String> {
|
||||
doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_attribute_lists_the_measured_layers() {
|
||||
let s = session();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let everything = all(doc);
|
||||
let but = |gone: &[&str]| -> BTreeSet<String> { everything.difference(&names(gone)).cloned().collect() };
|
||||
// Every visible layer and every visible group.
|
||||
assert_eq!(listed(doc, Attribute::Visible), but(&["hidden", "hidden group"]));
|
||||
assert!(listed(doc, Attribute::Visible).contains("in hidden group"), "a visible child of a hidden group");
|
||||
// Any lock counts, the Background's too.
|
||||
assert_eq!(listed(doc, Attribute::Locked), names(&["position lock", "all locked", "Background"]));
|
||||
// A pixel layer without pixels; the empty group isn't listed.
|
||||
assert_eq!(listed(doc, Attribute::Empty), names(&["empty"]));
|
||||
assert_eq!(listed(doc, Attribute::Linked), names(&["linked a", "linked b"]));
|
||||
// The clipped Levels layer, not its base.
|
||||
assert_eq!(listed(doc, Attribute::Clipped), names(&["clipped"]));
|
||||
// The adjustment and fill layers count with the masks they're created with.
|
||||
assert_eq!(listed(doc, Attribute::LayerMask), names(&["masked", "clipped", "fill"]));
|
||||
// A shape layer counts as having a vector mask.
|
||||
assert_eq!(listed(doc, Attribute::VectorMask), names(&["vector masked", "shape"]));
|
||||
// A layer whose only effect is turned off counts.
|
||||
assert_eq!(listed(doc, Attribute::LayerEffects), names(&["fx", "fx off"]));
|
||||
// Each Advanced Blending option on its own; Fill opacity alone doesn't count.
|
||||
assert_eq!(
|
||||
listed(doc, Attribute::AdvancedBlending),
|
||||
names(&["adv channels", "adv knockout", "adv interior", "adv clipped", "adv shapes", "adv layer mask", "adv vector mask", "adv blend if"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_erased_completely_is_empty() {
|
||||
// The Eraser only clears alpha, so the erased pixels keep their colour bytes.
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
let name = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).next().unwrap();
|
||||
let erase = |s: &mut photocraft_engine::Session, x: i32| {
|
||||
s.execute("paint.stroke", json!({"points": [[x, -10], [x, 40]], "size": 30, "erase": true, "brush": {"hardness": 1.0}})).unwrap();
|
||||
};
|
||||
erase(&mut s, 10);
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert_eq!(listed(doc, Attribute::Empty), names(&[]), "{depth}: partly erased");
|
||||
assert_eq!(listed(doc, Attribute::NotEmpty), names(&[&name]), "{depth}");
|
||||
erase(&mut s, 30);
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert_eq!(listed(doc, Attribute::Empty), names(&[&name]), "{depth}: erased completely");
|
||||
assert_eq!(listed(doc, Attribute::NotEmpty), names(&[]), "{depth}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_with_an_opaque_default_pixel_is_not_empty() {
|
||||
// No stored tiles, but the default pixel covers the whole canvas.
|
||||
let mut doc = Document::new("Opaque default", photocraft_geom::Size::new(40, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
|
||||
let opaque = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED);
|
||||
doc.layers.push(Layer::new("opaque", LayerContent::Raster(opaque)));
|
||||
doc.layers.push(Layer::new("clear", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8))));
|
||||
assert_eq!(listed(&doc, Attribute::Empty), names(&["clear"]));
|
||||
assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["opaque"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opaque_default_layer_erased_on_the_whole_canvas_is_empty() {
|
||||
// 300 px wide: the second column of tiles reaches past the canvas, where the default stays.
|
||||
let mut doc = Document::new("Opaque erased", photocraft_geom::Size::new(300, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
|
||||
let clear = [1.0, 0.0, 0.0, 0.0];
|
||||
for (name, erased) in [("erased", photocraft_geom::Rect::new(0, 0, 300, 30)), ("half erased", photocraft_geom::Rect::new(0, 0, 200, 30))] {
|
||||
let mut s = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED);
|
||||
s.fill_rect(erased, &clear);
|
||||
doc.layers.push(Layer::new(name, LayerContent::Raster(s)));
|
||||
}
|
||||
let mut dot = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED);
|
||||
dot.fill_rect(photocraft_geom::Rect::new(0, 0, 300, 30), &clear);
|
||||
dot.fill_rect(photocraft_geom::Rect::new(299, 29, 300, 30), &RED);
|
||||
doc.layers.push(Layer::new("one pixel", LayerContent::Raster(dot)));
|
||||
// The half erased layer has no tile over x 256..300, so the default shows there.
|
||||
assert_eq!(listed(&doc, Attribute::Empty), names(&["erased"]));
|
||||
assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["half erased", "one pixel"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_empty_layers_are_found_once_per_document() {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
app.ui.layer_filter_by = FilterBy::Attribute;
|
||||
app.ui.layer_filter_attribute = Attribute::Empty;
|
||||
let first = Filter::of_app(&app).empty.expect("found for Empty");
|
||||
assert!(first.is_empty(), "the filled layer has pixels");
|
||||
let again = Filter::of_app(&app).empty.unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&first, &again), "the same document reuses the result");
|
||||
// Selecting a layer moves the revision but changes no pixels.
|
||||
let id = app.session.active().unwrap().doc.layers[0].id;
|
||||
app.session.select_layer(id).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&first, &Filter::of_app(&app).empty.unwrap()), "a selection reuses the result");
|
||||
app.session.execute("paint.stroke", json!({"points": [[20, -10], [20, 40]], "size": 60, "erase": true, "brush": {"hardness": 1.0}})).unwrap();
|
||||
let after = Filter::of_app(&app);
|
||||
assert_eq!(after.empty.as_ref().map(|set| set.len()), Some(1), "an edit finds them again");
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
assert!(doc.walk().into_iter().all(|(_, _, l)| after.shows(l)), "the erased layer is listed");
|
||||
app.ui.layer_filter_attribute = Attribute::NotEmpty;
|
||||
assert!(Filter::of_app(&app).empty.is_some(), "Not Empty uses them too");
|
||||
// Another document, with no empty layer, gets its own result.
|
||||
app.session.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
assert_eq!(Filter::of_app(&app).empty.map(|set| set.len()), Some(0), "the new document's layers");
|
||||
app.ui.layer_filter_attribute = Attribute::Visible;
|
||||
assert!(Filter::of_app(&app).empty.is_none(), "other attributes don't read pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_opposite_lists_the_rest_groups_included() {
|
||||
let s = session();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let everything = all(doc);
|
||||
for (positive, negative) in Attribute::ALL[..9].iter().zip(&Attribute::ALL[9..]) {
|
||||
let rest: BTreeSet<String> = everything.difference(&listed(doc, *positive)).cloned().collect();
|
||||
assert_eq!(listed(doc, *negative), rest, "{negative:?} lists what {positive:?} doesn't");
|
||||
}
|
||||
// The three measured ones, spelled out.
|
||||
assert_eq!(listed(doc, Attribute::NotVisible), names(&["hidden", "hidden group"]));
|
||||
assert!(listed(doc, Attribute::NotLocked).contains("group"), "groups are listed too");
|
||||
assert!(!listed(doc, Attribute::NotLocked).contains("Background"));
|
||||
assert_eq!(listed(doc, Attribute::NotLinked), everything.difference(&names(&["linked a", "linked b"])).cloned().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_filters_at_once_with_visible() {
|
||||
let f = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, ..UiState::default() });
|
||||
assert_eq!(f.attribute, Attribute::Visible);
|
||||
assert!(f.active(), "Attribute filters as soon as it's picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_picks_the_attribute_by_id() {
|
||||
let mut ui = UiState::default();
|
||||
for attribute in Attribute::ALL {
|
||||
let f = super::patched(&ui, &json!({"by": "attribute", "attribute": attribute.id()})).unwrap();
|
||||
super::apply(&mut ui, f);
|
||||
assert_eq!((ui.layer_filter_by, ui.layer_filter_attribute), (FilterBy::Attribute, attribute));
|
||||
}
|
||||
assert_eq!(Attribute::NoAdvancedBlending.id(), "noAdvancedBlending");
|
||||
let err = super::patched(&ui, &json!({"attribute": "hidden"})).unwrap_err();
|
||||
assert!(err.contains("hidden") && err.contains("notVisible") && err.contains("noAdvancedBlending"), "{err}");
|
||||
assert!(super::patched(&ui, &json!({"attribute": 3})).is_err());
|
||||
assert!(super::patched(&ui, &json!({"attribute": null})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_settings_without_the_attribute_still_load() {
|
||||
let mut saved = serde_json::to_value(UiState { layer_filter_attribute: Attribute::NotLinked, ..UiState::default() }).unwrap();
|
||||
assert_eq!(saved["layer_filter_attribute"], "notLinked");
|
||||
saved.as_object_mut().unwrap().remove("layer_filter_attribute");
|
||||
let ui: UiState = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(ui.layer_filter_attribute, Attribute::Visible);
|
||||
}
|
||||
|
||||
/// The names of the rows the Layers panel drew.
|
||||
fn drawn(h: &Harness<'_, PhotocraftApp>) -> BTreeSet<String> {
|
||||
let doc = &h.state().session.active().unwrap().doc;
|
||||
let rows = crate::layer_row_ui::recorded(&h.ctx);
|
||||
rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_filters_by_the_attribute_picked_in_the_dropdown() {
|
||||
let session = session();
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(session, crate::Services::default())
|
||||
});
|
||||
h.run_steps(8);
|
||||
h.get_by_value("Kind").click();
|
||||
h.run_steps(4);
|
||||
h.query_all_by_label("Attribute").last().expect("Attribute in the open list").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.layer_filter_by, FilterBy::Attribute);
|
||||
assert!(!drawn(&h).contains("hidden"), "Visible at first");
|
||||
h.get_by_value("Visible").click();
|
||||
h.run_steps(4);
|
||||
h.query_all_by_label("Not Visible").last().expect("Not Visible in the open list").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.layer_filter_attribute, Attribute::NotVisible);
|
||||
assert_eq!(drawn(&h), names(&["hidden", "hidden group"]));
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! #10878: the Kind and Mode filters list the same layers as the reference application. Fill
|
||||
//! layers (solid colour without a vector shape, gradient, pattern) match no Kind button, a solid
|
||||
//! colour fill with a vector mask lists as a shape, and the Mode list has no Pass Through.
|
||||
|
||||
use photocraft_color::BlendMode;
|
||||
use photocraft_doc::Document;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Filter, FilterBy, KINDS};
|
||||
use crate::state::UiState;
|
||||
|
||||
fn new_session() -> photocraft_engine::Session {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Make a layer with `cmd`, name it `name` and return its id.
|
||||
fn make(s: &mut photocraft_engine::Session, cmd: &str, params: serde_json::Value, name: &str) -> u64 {
|
||||
let id = s.execute(cmd, params).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "name": name})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// The names `filter` lists, top to bottom.
|
||||
fn listed(doc: &Document, filter: &Filter) -> Vec<String> {
|
||||
crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn kinds(kinds: &[&str]) -> Filter {
|
||||
Filter::of(&UiState { layer_filter_by: FilterBy::Kind, layer_filter: kinds.iter().map(|k| k.to_string()).collect(), ..UiState::default() })
|
||||
}
|
||||
|
||||
fn mode(mode: BlendMode) -> Filter {
|
||||
Filter::of(&UiState { layer_filter_by: FilterBy::Mode, layer_filter_mode: mode, ..UiState::default() })
|
||||
}
|
||||
|
||||
/// Every kind of fill layer, a solid fill with a vector mask, and a Gradient Map adjustment.
|
||||
fn fills() -> photocraft_engine::Session {
|
||||
let mut s = new_session();
|
||||
make(&mut s, "layer.newFillLayer.solidColor", json!({"color": "#112233"}), "Solid");
|
||||
let shaped = make(&mut s, "layer.newFillLayer.solidColor", json!({"color": "#445566"}), "Solid shape");
|
||||
s.execute("layer.vectorMask.revealAll", json!({"layer": shaped})).unwrap();
|
||||
make(&mut s, "layer.newFillLayer.gradient", json!({}), "Gradient");
|
||||
make(&mut s, "layer.newFillLayer.pattern", json!({"pattern": "Checkerboard"}), "Pattern");
|
||||
make(&mut s, "layer.newAdjustmentLayer.gradientMap", json!({}), "Gradient Map");
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_layers_match_no_kind_button_and_a_shaped_solid_fill_is_a_shape() {
|
||||
let s = fills();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
// Each button on its own: the measured table, row by row.
|
||||
assert_eq!(listed(doc, &kinds(&["adjustment"])), ["Gradient Map"], "only the adjustment layer is an adjustment");
|
||||
assert_eq!(listed(doc, &kinds(&["shape"])), ["Solid shape"], "a solid fill with a vector mask lists as a shape");
|
||||
assert!(listed(doc, &kinds(&["pixel"])).iter().all(|n| n == "Background"), "no fill layer is a pixel layer");
|
||||
// With all five buttons on, the plain solid, gradient and pattern fills are still not listed.
|
||||
let all = listed(doc, &kinds(&KINDS));
|
||||
for fill in ["Solid", "Gradient", "Pattern"] {
|
||||
assert!(!all.iter().any(|n| n == fill), "{fill} matches no Kind button: {all:?}");
|
||||
}
|
||||
assert!(all.iter().any(|n| n == "Solid shape") && all.iter().any(|n| n == "Gradient Map"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_group_is_listed_by_mode_and_a_pass_through_group_never_is() {
|
||||
let mut s = new_session();
|
||||
make(&mut s, "layer.new.layer", json!({}), "Child");
|
||||
let normal = s.execute("layer.groupLayers", json!({"name": "Normal group"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": normal, "blend": "Normal"})).unwrap();
|
||||
let child = {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
doc.walk().into_iter().find(|(_, _, l)| l.name == "Child").map(|(_, _, l)| l.id.0).unwrap()
|
||||
};
|
||||
s.execute("layer.setProps", json!({"layer": child, "blend": "Multiply"})).unwrap();
|
||||
make(&mut s, "layer.new.layer", json!({}), "Other");
|
||||
s.execute("layer.groupLayers", json!({"name": "Pass group"})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let pass = doc.walk().into_iter().find(|(_, _, l)| l.name == "Pass group").map(|(_, _, l)| l.blend).unwrap();
|
||||
assert_eq!(pass, BlendMode::PassThrough, "a new group passes through");
|
||||
|
||||
let normal_rows = listed(doc, &mode(BlendMode::Normal));
|
||||
assert!(normal_rows.iter().any(|n| n == "Normal group"), "a Normal group is listed under Normal: {normal_rows:?}");
|
||||
assert!(!normal_rows.iter().any(|n| n == "Child"), "its Multiply child is not");
|
||||
assert!(listed(doc, &mode(BlendMode::Multiply)).iter().any(|n| n == "Child"), "a Multiply layer inside a group is listed under Multiply");
|
||||
assert!(!normal_rows.iter().any(|n| n == "Pass group"), "a Pass Through group is not");
|
||||
for m in BlendMode::LAYER_MODES {
|
||||
assert!(!listed(doc, &mode(m)).iter().any(|n| n == "Pass group"), "no mode lists the Pass Through group ({m:?})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mode_filter_takes_no_pass_through() {
|
||||
let ui = UiState::default();
|
||||
let err = super::patched(&ui, &json!({"mode": "Pass Through"})).unwrap_err();
|
||||
assert!(err.contains("Pass Through"), "the error names what was asked for: {err}");
|
||||
for m in BlendMode::LAYER_MODES {
|
||||
assert!(err.contains(m.label()), "the error names the valid mode {}: {err}", m.label());
|
||||
}
|
||||
// The 27 layer modes still work.
|
||||
assert_eq!(BlendMode::LAYER_MODES.len(), 27);
|
||||
assert_eq!(super::patched(&ui, &json!({"mode": "Multiply"})).unwrap().mode, BlendMode::Multiply);
|
||||
assert_eq!(super::patched(&ui, &json!({"mode": "Normal"})).unwrap().mode, BlendMode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_pass_through_filter_mode_loads_as_normal() {
|
||||
let mut stored = serde_json::to_value(UiState::default()).unwrap();
|
||||
stored["layer_filter_mode"] = serde_json::to_value(BlendMode::PassThrough).unwrap();
|
||||
let ui: UiState = serde_json::from_value(stored.clone()).unwrap();
|
||||
assert_eq!(ui.layer_filter_mode, BlendMode::Normal);
|
||||
// Any other stored mode is kept as it was.
|
||||
stored["layer_filter_mode"] = serde_json::to_value(BlendMode::Screen).unwrap();
|
||||
let ui: UiState = serde_json::from_value(stored).unwrap();
|
||||
assert_eq!(ui.layer_filter_mode, BlendMode::Screen);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! #10879: applying the Layers panel filter, or changing its criteria while it filters, deselects
|
||||
//! the selected layers it no longer lists, as in Photoshop. The listed ones stay selected (one of
|
||||
//! them becomes the active layer when the active one is hidden), and turning the filter off changes
|
||||
//! nothing. Like any layer selection this is no history step. A layer selected later, while the
|
||||
//! filter hides it, stays selected: the filter only acts when it changes.
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
use serde_json::json;
|
||||
|
||||
use super::Filter;
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Deselect the layers the filter hides when its criteria changed since the last call. The
|
||||
/// criteria are kept in `ctx`'s memory, so a change from the filter row or from `ui.set
|
||||
/// {layerFilter}` is seen the next time the row is drawn; with nothing kept yet (the row's first
|
||||
/// draw, e.g. after `ui.set` while the panel was closed) a filter that is on counts as applied.
|
||||
pub fn follow(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let id = egui::Id::unique("layer-filter-applied");
|
||||
let now = Filter::of(&app.ui);
|
||||
let before = ctx.data_mut(|d| {
|
||||
let before = d.get_temp::<Filter>(id);
|
||||
d.insert_temp(id, now.clone());
|
||||
before
|
||||
});
|
||||
if before.is_none_or(|b| b != now) && now.active() {
|
||||
deselect_hidden(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deselect the active document's selected layers that [`Filter::of_app`] doesn't list: through
|
||||
/// `select.deselectLayers` when none stays listed, else by keeping only the listed ones.
|
||||
pub fn deselect_hidden(app: &mut PhotocraftApp) {
|
||||
let f = Filter::of_app(app);
|
||||
if !f.active() {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let selected = st.selected_layers();
|
||||
let listed: Vec<LayerId> = selected.iter().copied().filter(|id| st.doc.layer(*id).is_some_and(|l| f.shows(l))).collect();
|
||||
if listed.len() == selected.len() && st.active_layer.is_none_or(|a| listed.contains(&a)) {
|
||||
return;
|
||||
}
|
||||
if listed.is_empty() {
|
||||
let _ = app.run("select.deselectLayers", json!({}));
|
||||
return;
|
||||
}
|
||||
let active = st.active_layer.filter(|a| listed.contains(a)).or(listed.last().copied());
|
||||
let anchor = st.layer_anchor.filter(|a| listed.contains(a)).or(active);
|
||||
let _ = photocraft_engine::layer_multi_cmds::set_selection(&mut app.session, listed, active, anchor);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! #10879: applying a Layers panel filter deselects the selected layers it hides, against the
|
||||
//! cases measured in Photoshop.
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::Harness;
|
||||
use photocraft_doc::LayerId;
|
||||
use serde_json::json;
|
||||
|
||||
use super::FilterBy;
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// "Red" and "Deep Pixel" (pixel layers) and "Hello" (type) over the Background, in the panel.
|
||||
fn harness() -> Harness<'static, PhotocraftApp> {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Red"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Deep Pixel"})).unwrap();
|
||||
let text = s.execute("type.create", json!({"text": "Hello", "x": 4, "y": 20, "size": 12})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": text, "name": "Hello"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
fn id(h: &Harness<'_, PhotocraftApp>, name: &str) -> LayerId {
|
||||
h.state()
|
||||
.session
|
||||
.active()
|
||||
.unwrap()
|
||||
.doc
|
||||
.walk()
|
||||
.into_iter()
|
||||
.find(|(_, _, l)| l.name == name)
|
||||
.map(|(_, _, l)| l.id)
|
||||
.unwrap_or_else(|| panic!("no layer {name}"))
|
||||
}
|
||||
|
||||
fn select(h: &mut Harness<'_, PhotocraftApp>, names: &[&str]) {
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let layer = id(h, name).0;
|
||||
h.state_mut().session.execute("layer.select", json!({"layer": layer, "mode": if i == 0 { "replace" } else { "add" }})).unwrap();
|
||||
}
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// The selected layers' names, bottom to top, and the active layer's.
|
||||
fn selection(h: &Harness<'_, PhotocraftApp>) -> (Vec<String>, Option<String>) {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let name = |id: LayerId| st.doc.layer(id).unwrap().name.clone();
|
||||
(st.selected_layers().into_iter().map(name).collect(), st.active_layer.map(name))
|
||||
}
|
||||
|
||||
fn none() -> (Vec<String>, Option<String>) {
|
||||
(Vec::new(), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_that_hides_the_selected_layer_deselects_it_and_turning_it_off_keeps_that() {
|
||||
let mut h = harness();
|
||||
select(&mut h, &["Red"]);
|
||||
h.state_mut().ui.layer_filter = vec!["type".into()];
|
||||
h.run_steps(4);
|
||||
assert_eq!(selection(&h), none(), "Kind › Type hides Red: no layer is selected");
|
||||
h.state_mut().ui.layer_filter.clear();
|
||||
h.run_steps(4);
|
||||
assert_eq!(selection(&h), none(), "turning the filter off does not bring the selection back");
|
||||
}
|
||||
|
||||
/// Batch 16 review: a filter already on when the row is first drawn (set before the panel drew,
|
||||
/// as `ui.set {layerFilter}` can) deselects the layers it hides too.
|
||||
#[test]
|
||||
fn a_filter_on_before_the_rows_first_draw_deselects_the_hidden_layers() {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Red"})).unwrap();
|
||||
s.execute("type.create", json!({"text": "Hello", "x": 4, "y": 20, "size": 12})).unwrap();
|
||||
let red = s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == "Red").map(|(_, _, l)| l.id).unwrap();
|
||||
s.execute("layer.select", json!({"layer": red.0})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
app.ui.layer_filter = vec!["type".into()];
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
assert_eq!(selection(&h), none(), "Kind › Type hides Red from the first draw on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_that_lists_none_of_the_selected_layers_deselects_them_all() {
|
||||
let mut h = harness();
|
||||
select(&mut h, &["Hello", "Deep Pixel"]);
|
||||
h.state_mut().ui.layer_filter_by = FilterBy::SmartObject;
|
||||
h.state_mut().ui.layer_filter_smart = vec!["embedded".into()];
|
||||
h.run_steps(4);
|
||||
assert_eq!(selection(&h), none(), "Smart Object › Embedded matches neither");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listed_layers_stay_selected_and_one_of_them_becomes_active() {
|
||||
let mut h = harness();
|
||||
select(&mut h, &["Red", "Hello", "Deep Pixel"]);
|
||||
assert_eq!(selection(&h).1.as_deref(), Some("Deep Pixel"));
|
||||
h.state_mut().ui.layer_filter = vec!["type".into()];
|
||||
h.run_steps(4);
|
||||
assert_eq!(selection(&h), (vec!["Hello".to_string()], Some("Hello".to_string())));
|
||||
let history = h.state().session.active().unwrap().history.entries().len();
|
||||
// Changing the criteria while the filter is on acts again: Name "zzzz" lists nothing.
|
||||
h.state_mut().ui.layer_filter_by = FilterBy::Name;
|
||||
h.state_mut().ui.layer_filter_name = "zzzz".into();
|
||||
h.run_steps(4);
|
||||
assert_eq!(selection(&h), none());
|
||||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), history, "deselecting is no history step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_selected_while_the_filter_hides_it_stays_selected() {
|
||||
let mut h = harness();
|
||||
select(&mut h, &["Hello"]);
|
||||
h.state_mut().ui.layer_filter = vec!["type".into()];
|
||||
h.run_steps(4);
|
||||
assert_eq!(selection(&h).0, ["Hello"]);
|
||||
select(&mut h, &["Red"]);
|
||||
assert_eq!(selection(&h).0, ["Red"], "the filter only acts when it is applied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_set_through_the_control_channel_deselects_too() {
|
||||
let mut h = harness();
|
||||
select(&mut h, &["Red"]);
|
||||
let f = super::patched(&h.state().ui, &json!({"by": "name", "name": "hello"})).unwrap();
|
||||
super::apply(&mut h.state_mut().ui, f);
|
||||
h.run_steps(4);
|
||||
assert_eq!(selection(&h), none());
|
||||
}
|
||||
@@ -540,7 +540,7 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool:
|
||||
crate::canvas::commit_crop(app);
|
||||
}
|
||||
if icons::button(ui, "ban", 26.0, false, tl!("Cancel current crop operation (Esc)")).clicked() {
|
||||
crate::crop_ui::cancel(app);
|
||||
crate::crop_ui::dismiss(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ pub struct SpellDialog {
|
||||
pub ignore: Vec<String>,
|
||||
pub change_to: String,
|
||||
pub all_layers: bool,
|
||||
/// Single-layer checks remain bound to their opening layer across refreshes and toggles.
|
||||
#[serde(skip)]
|
||||
pub layer: Option<photocraft_doc::LayerId>,
|
||||
pub message: String,
|
||||
/// The token of the action playback that opened the dialog at a Check Spelling step: Done
|
||||
/// continues it ([`crate::actions::resume`]).
|
||||
@@ -763,10 +766,18 @@ pub fn glyphs_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
|
||||
// ------------------------------------------------------------------ spelling
|
||||
|
||||
/// Refresh the opening layer when checking one layer; an absent opening receiver must not adopt the newly active layer.
|
||||
fn refresh_spelling(app: &mut PhotocraftApp) {
|
||||
let Some(d) = app.ui.type_panels.spell.clone() else { return };
|
||||
let p = json!({ "action": "list", "allLayers": d.all_layers, "ignore": d.ignore, "suggestions": 8 });
|
||||
let items = run(app, "edit.checkSpelling", p).and_then(|r| r["misspellings"].as_array().cloned()).unwrap_or_default();
|
||||
let mut p = json!({ "action": "list", "allLayers": d.all_layers, "ignore": d.ignore, "suggestions": 8 });
|
||||
if let Some(layer) = d.layer.filter(|_| !d.all_layers) {
|
||||
p["layer"] = json!(layer.0);
|
||||
}
|
||||
let items = if !d.all_layers && d.layer.is_none() {
|
||||
Vec::new()
|
||||
} else {
|
||||
run(app, "edit.checkSpelling", p).and_then(|r| r["misspellings"].as_array().cloned()).unwrap_or_default()
|
||||
};
|
||||
if let Some(s) = app.ui.type_panels.spell.as_mut() {
|
||||
s.items = items;
|
||||
s.change_to = s.items.get(s.index).and_then(|i| i["suggestions"][0].as_str()).unwrap_or("").to_string();
|
||||
@@ -776,9 +787,14 @@ fn refresh_spelling(app: &mut PhotocraftApp) {
|
||||
/// Opens Edit › Check Spelling (returns the misspellings). Opening it again while an action waits
|
||||
/// at a Check Spelling step keeps that action's token, so Done still continues it.
|
||||
pub fn open_spelling(app: &mut PhotocraftApp, all_layers: bool) -> Result<Value, String> {
|
||||
app.session.execute("edit.checkSpelling", json!({ "action": "list", "allLayers": all_layers })).map_err(|e| e.to_string())?;
|
||||
let resume = app.ui.type_panels.spell.as_ref().and_then(|d| d.resume);
|
||||
app.ui.type_panels.spell = Some(SpellDialog { all_layers, resume, ..Default::default() });
|
||||
let layer = if resume.is_some() { app.ui.type_panels.spell.as_ref().and_then(|d| d.layer) } else { app.session.active().and_then(|st| st.active_layer) };
|
||||
let mut params = json!({"action":"list","allLayers":all_layers});
|
||||
if !all_layers {
|
||||
params["layer"] = json!(layer.ok_or("no layer was selected for this spelling check")?.0);
|
||||
}
|
||||
app.session.execute("edit.checkSpelling", params).map_err(|e| e.to_string())?;
|
||||
app.ui.type_panels.spell = Some(SpellDialog { all_layers, layer, resume, ..Default::default() });
|
||||
refresh_spelling(app);
|
||||
let d = app.ui.type_panels.spell.as_ref().map(|d| d.items.clone()).unwrap_or_default();
|
||||
Ok(json!({ "dialog": SPELLING, "misspellings": d }))
|
||||
@@ -802,6 +818,16 @@ pub fn close_spelling(app: &mut PhotocraftApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind single-layer Change All to the listed item; all-layer corrections intentionally omit a layer restriction.
|
||||
fn spelling_change_all_params(item: &Value, word: &str, replace: &str, all_layers: bool) -> Value {
|
||||
let mut params = json!({"action":"changeAll", "word":word, "replace":replace, "allLayers":all_layers});
|
||||
if !all_layers {
|
||||
params["layer"] = item["layer"].clone();
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// Render spelling navigation and corrections using the retained opening receiver and paused-action continuation.
|
||||
fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
let Some(d) = app.ui.type_panels.spell.clone() else { return };
|
||||
@@ -930,9 +956,12 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let p = if a == "change" {
|
||||
json!({ "action": "change", "layer": i["layer"], "start": i["start"], "end": i["end"], "word": word, "replace": change_to })
|
||||
} else {
|
||||
json!({ "action": "changeAll", "word": word, "replace": change_to, "allLayers": all_layers })
|
||||
spelling_change_all_params(i, &word, &change_to, all_layers)
|
||||
};
|
||||
let _ = run(app, "edit.checkSpelling", p);
|
||||
if let Err(error) = crate::actions::spelling_change(app, p) {
|
||||
app.ui.status = error;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
refresh_spelling(app);
|
||||
}
|
||||
}
|
||||
@@ -968,6 +997,61 @@ mod tests {
|
||||
assert!(app.ui.type_panels.glyphs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Single-layer Change All edits the listed text layer even after the host selects another layer.
|
||||
fn change_all_keeps_the_listed_layer_after_a_session_selection_change() {
|
||||
for all_layers in [false, true] {
|
||||
let mut app = app_with_text("Teh cat");
|
||||
let listed = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.run("type.create", json!({"x":10,"y":90,"text":"Teh dog"})).unwrap();
|
||||
let other = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.select_layer(listed).unwrap();
|
||||
menu(&mut app, "edit.checkSpelling", &json!({"allLayers":false})).unwrap().unwrap();
|
||||
app.session.select_layer(other).unwrap();
|
||||
refresh_spelling(&mut app);
|
||||
let item = app.ui.type_panels.spell.as_ref().unwrap().items[0].clone();
|
||||
assert_eq!(item["layer"], listed.0);
|
||||
let params = spelling_change_all_params(&item, "Teh", "The", all_layers);
|
||||
if all_layers {
|
||||
assert!(params.get("layer").is_none());
|
||||
}
|
||||
crate::actions::spelling_change(&mut app, params).unwrap();
|
||||
refresh_spelling(&mut app);
|
||||
assert!(app.ui.type_panels.spell.as_ref().unwrap().items.is_empty());
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
for (id, expected) in [(listed, "The cat"), (other, if all_layers { "The dog" } else { "Teh dog" })] {
|
||||
let photocraft_doc::LayerContent::Text(text) = &doc.layer(id).unwrap().content else { panic!("not text") };
|
||||
assert_eq!(text.text, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Refreshing and toggling Check All Layers must retain the original single-layer receiver, including the absent-layer case.
|
||||
fn spelling_refresh_and_all_layers_toggles_keep_the_opening_layer() {
|
||||
let mut app = app_with_text("Teh cat");
|
||||
let original = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.run("type.create", json!({"x":10,"y":90,"text":"Teh dog"})).unwrap();
|
||||
let other = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.select_layer(original).unwrap();
|
||||
open_spelling(&mut app, false).unwrap();
|
||||
app.session.select_layer(other).unwrap();
|
||||
for all_layers in [false, true, false] {
|
||||
app.ui.type_panels.spell.as_mut().unwrap().all_layers = all_layers;
|
||||
refresh_spelling(&mut app);
|
||||
let items = &app.ui.type_panels.spell.as_ref().unwrap().items;
|
||||
assert!(items.iter().any(|item| item["layer"] == original.0));
|
||||
assert_eq!(items.iter().any(|item| item["layer"] == other.0), all_layers);
|
||||
}
|
||||
app.session.restore_layer_selection(None, &[], None).unwrap();
|
||||
open_spelling(&mut app, true).unwrap();
|
||||
assert!(app.ui.type_panels.spell.as_ref().unwrap().layer.is_none());
|
||||
app.session.select_layer(other).unwrap();
|
||||
app.ui.type_panels.spell.as_mut().unwrap().all_layers = false;
|
||||
refresh_spelling(&mut app);
|
||||
assert!(app.ui.type_panels.spell.as_ref().unwrap().items.is_empty(), "a missing opening target must not adopt the new active layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_insertion_and_recent() {
|
||||
let mut app = app_with_text("ab");
|
||||
|
||||
@@ -297,6 +297,11 @@ fn a_hidden_filter_row_is_deselected() {
|
||||
h.state_mut().ui.layer_filter_name.clear();
|
||||
h.run_steps(4);
|
||||
assert!(!selected(&h, "Gaussian Blur"));
|
||||
// The Name filter hid Smart A too, so it deselected it (#10879): select it again.
|
||||
assert!(h.state().session.active().unwrap().selected_layers().is_empty());
|
||||
h.state_mut().session.execute("layer.select", json!({"layer": a})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!selected(&h, "Gaussian Blur"), "selecting the layer does not bring the filter row back");
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(4);
|
||||
let st = h.state().session.active().unwrap();
|
||||
|
||||
@@ -16,7 +16,7 @@ pub use panel_state::{
|
||||
};
|
||||
pub use tool::Tool;
|
||||
pub use tool_options::{SampleAllLayers, ToolOptions};
|
||||
use tool_options::{default_brush_tool, default_layer_filter_mode, yes};
|
||||
use tool_options::{default_brush_tool, default_layer_filter_mode, layer_filter_mode_or_normal, yes};
|
||||
pub use transform::{
|
||||
BoxResize, CamTransform, ContentAwareOptions, Extras, MadeLayer, TextEdit, TransformMode, TransformSession, TypeInsertionStyle, TypeTransform,
|
||||
};
|
||||
@@ -365,11 +365,14 @@ pub struct UiState {
|
||||
#[serde(default)]
|
||||
pub layer_filter_name: String,
|
||||
/// Layers panel blend-mode filter.
|
||||
#[serde(default = "default_layer_filter_mode")]
|
||||
#[serde(default = "default_layer_filter_mode", deserialize_with = "layer_filter_mode_or_normal")]
|
||||
pub layer_filter_mode: photocraft_color::BlendMode,
|
||||
/// Layers panel effect filter (#10880).
|
||||
#[serde(default)]
|
||||
pub layer_filter_effect: crate::layer_filter_ui::effect::EffectKind,
|
||||
/// Layers panel attribute filter (#10881).
|
||||
#[serde(default)]
|
||||
pub layer_filter_attribute: crate::layer_filter_ui::attribute::Attribute,
|
||||
/// Layers panel colour filter (#5898): the label colour listed (None: layers without one).
|
||||
#[serde(default)]
|
||||
pub layer_filter_color: photocraft_doc::LabelColor,
|
||||
@@ -586,6 +589,7 @@ impl Default for UiState {
|
||||
layer_filter_name: String::new(),
|
||||
layer_filter_mode: default_layer_filter_mode(),
|
||||
layer_filter_effect: Default::default(),
|
||||
layer_filter_attribute: Default::default(),
|
||||
layer_filter_color: Default::default(),
|
||||
pen: None,
|
||||
pen_doc: None,
|
||||
|
||||
@@ -313,6 +313,14 @@ pub(super) fn default_layer_filter_mode() -> photocraft_color::BlendMode {
|
||||
photocraft_color::BlendMode::Normal
|
||||
}
|
||||
|
||||
/// A stored Mode filter set to Pass Through, which the Mode list offered until #10878, loads as
|
||||
/// Normal: Pass Through isn't a mode the filter lists any more.
|
||||
pub(super) fn layer_filter_mode_or_normal<'de, D: serde::Deserializer<'de>>(d: D) -> Result<photocraft_color::BlendMode, D::Error> {
|
||||
use serde::Deserialize;
|
||||
let mode = photocraft_color::BlendMode::deserialize(d)?;
|
||||
Ok(if mode == photocraft_color::BlendMode::PassThrough { photocraft_color::BlendMode::Normal } else { mode })
|
||||
}
|
||||
|
||||
fn one_u32() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ fn frame(app: &mut PhotocraftApp) {
|
||||
if app.ui.crop_rect.is_some() || app.crop.drag.is_some() {
|
||||
return;
|
||||
}
|
||||
// A committed crop hid the frame: it stays hidden for this document until the next press.
|
||||
// A committed crop or Esc hid the frame: it stays hidden for this document until the next press.
|
||||
if app.crop.dismissed.is_some() && app.crop.dismissed == dismiss_key(app) {
|
||||
return;
|
||||
}
|
||||
@@ -490,6 +490,13 @@ pub(crate) fn show_frame(app: &mut PhotocraftApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Esc or the options bar's cancel: [`cancel`], then [`hide_frame`], so the frame disappears as in
|
||||
/// Photoshop (#10370) and stays hidden as after a commit.
|
||||
pub fn dismiss(app: &mut PhotocraftApp) {
|
||||
cancel(app);
|
||||
hide_frame(app);
|
||||
}
|
||||
|
||||
/// The active document's identity for [`CropState::dismissed`].
|
||||
fn dismiss_key(app: &PhotocraftApp) -> Option<(photocraft_doc::DocId, u32, u32)> {
|
||||
app.session.active().map(|st| (st.doc.id, st.doc.size.width, st.doc.size.height))
|
||||
|
||||
@@ -1217,6 +1217,128 @@ fn readout_shows_the_frame_size_while_drawing_or_resizing() {
|
||||
assert_eq!(frame_size([-f64::MAX, 0.0, f64::MAX, 1.0]), None);
|
||||
}
|
||||
|
||||
/// Esc through the real shortcut handler.
|
||||
fn press_esc(app: &mut PhotocraftApp) {
|
||||
let key = egui::Key::Escape;
|
||||
let input = egui::RawInput {
|
||||
focused: true,
|
||||
events: vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }],
|
||||
..Default::default()
|
||||
};
|
||||
egui::Context::default().run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_hides_the_frame_until_a_press() {
|
||||
// #10370: Esc cancelled the crop but the next frame made the default frame again. It now hides
|
||||
// the frame as a commit does: picking the tool again keeps it hidden, a press brings it back.
|
||||
for classic in [false, true] {
|
||||
let mut app = app(SampleType::U8);
|
||||
app.ui.tool_options.crop_shield.classic_mode = classic;
|
||||
ensure_frame(&mut app);
|
||||
assert!(app.ui.crop_rect.is_some() && app.crop.default_frame);
|
||||
// The untouched default frame: Esc hides it, and it stays hidden frame after frame.
|
||||
press_esc(&mut app);
|
||||
for _ in 0..3 {
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None, "classic={classic}");
|
||||
}
|
||||
// A press on the canvas draws a new frame.
|
||||
drag(&mut app, &[[50.0, 20.0], [80.0, 40.0], [120.0, 70.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([50.0, 20.0, 120.0, 70.0]));
|
||||
// A drawn frame: Esc hides it too, and crops nothing.
|
||||
press_esc(&mut app);
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None);
|
||||
assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100));
|
||||
// Leaving the tool after Esc crops nothing, and back on it the frame stays hidden.
|
||||
app.ui.tool = Tool::Brush;
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100), "classic={classic}");
|
||||
app.ui.tool = Tool::Crop;
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None, "picking the tool again keeps it hidden");
|
||||
// A press brings it back; the options bar's cancel hides it as well.
|
||||
show_frame(&mut app);
|
||||
assert!(app.ui.crop_rect.is_some() && app.crop.default_frame);
|
||||
dismiss(&mut app);
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None);
|
||||
// A plain cancel (stale document) does not hide it.
|
||||
show_frame(&mut app);
|
||||
cancel(&mut app);
|
||||
ensure_frame(&mut app);
|
||||
assert!(app.ui.crop_rect.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_dismissed_document_never_hides_the_next_tabs_frame() {
|
||||
// A same-size tab that takes the closed document's index must still show its frame.
|
||||
let mut app = app(SampleType::U8);
|
||||
let doc = Document::with_background("other", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
app.session.add_document(doc, None);
|
||||
assert!(app.session.set_active(0));
|
||||
ensure_frame(&mut app);
|
||||
press_esc(&mut app);
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None);
|
||||
assert!(app.session.close(0).is_some());
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
ensure_frame(&mut app);
|
||||
assert!(app.ui.crop_rect.is_some() && app.crop.default_frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straighten_press_ends_the_dismissal() {
|
||||
// Straighten takes the press before `begin_drag`; a click too short to level anything still
|
||||
// ends the dismissal.
|
||||
let mut app = app(SampleType::U8);
|
||||
ensure_frame(&mut app);
|
||||
press_esc(&mut app);
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None);
|
||||
app.crop.straighten.armed = true;
|
||||
drag(&mut app, &[[50.0, 20.0], [50.5, 20.0]], NONE);
|
||||
ensure_frame(&mut app);
|
||||
assert!(app.ui.crop_rect.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_a_crop_after_esc_brings_the_frame_back() {
|
||||
// Esc crops nothing, so it adds no step; an Undo that changes the canvas size shows a frame for it.
|
||||
let mut app = app(SampleType::U8);
|
||||
ensure_frame(&mut app);
|
||||
drag(&mut app, &[[20.0, 10.0], [70.0, 30.0], [120.0, 60.0]], NONE);
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50));
|
||||
ensure_frame(&mut app);
|
||||
let steps = app.session.active().unwrap().history.entries().len();
|
||||
press_esc(&mut app);
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None);
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), steps);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100));
|
||||
ensure_frame(&mut app);
|
||||
assert!(app.ui.crop_rect.is_some() && app.crop.default_frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undo_that_keeps_the_canvas_size_leaves_the_frame_hidden() {
|
||||
// Only a size change brings the frame back: undoing an edit that keeps the size doesn't.
|
||||
let mut app = app(SampleType::U8);
|
||||
app.run("image.adjustments.invert", json!({})).unwrap();
|
||||
ensure_frame(&mut app);
|
||||
press_esc(&mut app);
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100));
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, None);
|
||||
}
|
||||
|
||||
/// #3145: the Info panel shows the pending frame's W × H while the Crop tool is active
|
||||
/// (between gestures too, and its own size when turned), and nothing for other tools.
|
||||
#[test]
|
||||
|
||||
@@ -29,6 +29,10 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
||||
let id = st.active_layer.ok_or("no active layer")?;
|
||||
// Several layers selected: one box moves them all, as in Photoshop (#6501).
|
||||
let targets = photocraft_engine::transform_cmds::selection_targets(st);
|
||||
// The selection takes its linked layers along, as `edit.transform` does: in Photoshop the box
|
||||
// over one linked layer frames and moves the whole link group (#11217), but not a linked
|
||||
// Background (`box_targets`). Unlinked, unchanged.
|
||||
let targets = photocraft_engine::transform_cmds::box_targets(&doc, &targets);
|
||||
if targets != [id] {
|
||||
return begin_several(app, ctx, doc, revision, id, targets);
|
||||
}
|
||||
@@ -251,6 +255,19 @@ pub const WARP_ONE_LAYER: &str = "Warp works on one layer at a time: select a si
|
||||
/// its selected pixels, as Layer via Copy) and transforms the copy (#352).
|
||||
pub fn begin_copy(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
|
||||
let selection = app.session.active().is_some_and(|d| d.doc.selection.is_some());
|
||||
// A linked layer without a selection: the reference application makes no copy and frames the
|
||||
// whole link group, as Free Transform does (measured, #11217). Unlinked layers get the copy,
|
||||
// and so does a targeted mask (a lone target).
|
||||
let target = crate::canvas::paint_target(app);
|
||||
let linked = !selection
|
||||
&& app.session.active().is_some_and(|st| {
|
||||
let lone = photocraft_engine::transform_cmds::lone_target(&st.doc, st.active_layer, &json!({ "target": target }));
|
||||
let targets = photocraft_engine::transform_cmds::selection_targets(st);
|
||||
matches!(lone, Ok(None)) && photocraft_engine::transform_cmds::box_targets(&st.doc, &targets) != targets
|
||||
});
|
||||
if linked {
|
||||
return begin(app, ctx);
|
||||
}
|
||||
let params = if selection { json!({}) } else { json!({"inPlace": true}) };
|
||||
app.run(if selection { "layer.new.layerViaCopy" } else { "layer.duplicate" }, params)?;
|
||||
let made = app.session.active().and_then(|st| st.active_layer.map(|layer| (st.doc.id, layer)));
|
||||
|
||||
@@ -2066,6 +2066,122 @@ fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp {
|
||||
app
|
||||
}
|
||||
|
||||
/// `app_with_square` (A, red over 8..24) plus B (x 40..56, y 8..24) and C (x 8..24, y 40..56)
|
||||
/// over it. A and B are linked; C isn't. Only A is selected, and active: (app, A, B, C).
|
||||
fn linked_layers_app() -> (PhotocraftApp, LayerId, LayerId, LayerId) {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
let add = |app: &mut PhotocraftApp, fill: photocraft_geom::Rect| {
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
app.session
|
||||
.edit("paint", |doc, l| {
|
||||
doc.layer_mut(l.unwrap()).unwrap().surface_mut().unwrap().fill_rect(fill, &[0.0, 0.0, 1.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
app.session.active().unwrap().active_layer.unwrap()
|
||||
};
|
||||
let b = add(&mut app, photocraft_geom::Rect::new(40, 8, 56, 24));
|
||||
let c = add(&mut app, photocraft_geom::Rect::new(8, 40, 24, 56));
|
||||
app.session.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
app.session.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(app.session.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||||
app.session.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
(app, a, b, c)
|
||||
}
|
||||
|
||||
/// #11217: ⌥⌘T on a linked layer without a selection makes no copy in the reference application
|
||||
/// (measured): it frames and moves the whole link group, as Free Transform does. An unlinked
|
||||
/// layer still gets its copy.
|
||||
#[test]
|
||||
fn free_transform_a_copy_of_a_linked_layer_boxes_the_link_group_without_a_copy() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut app, a, b, c) = linked_layers_app();
|
||||
let layers = app.session.active().unwrap().doc.layers.len();
|
||||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "no copy of a linked layer");
|
||||
let t = app.ui.transform.as_ref().unwrap();
|
||||
assert_eq!(t.made, None, "nothing made to take back");
|
||||
assert_eq!(t.layers, [a.0, b.0], "the box frames the link group");
|
||||
cancel(&mut app);
|
||||
app.session.execute("layer.select", json!({"layer": c.0})).unwrap();
|
||||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "an unlinked layer is copied");
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().made, Some(MadeLayer::Copy));
|
||||
cancel(&mut app);
|
||||
}
|
||||
|
||||
/// #11217: the box over one selected linked layer frames, previews and commits its whole link
|
||||
/// group, as in Photoshop. It used to frame A alone; B only moved at the commit.
|
||||
#[test]
|
||||
fn free_transform_of_a_linked_layer_boxes_its_link_group() {
|
||||
let (mut app, a, b, c) = linked_layers_app();
|
||||
begin(&mut app, &egui::Context::default()).unwrap();
|
||||
let t = app.ui.transform.as_ref().unwrap();
|
||||
assert_eq!(t.layer, a.0, "the active layer stays the session's");
|
||||
assert_eq!(t.layers, [a.0, b.0], "a box over the link group, in stack order");
|
||||
assert_eq!(t.rect, [8.0, 8.0, 56.0, 24.0], "framing A and B together");
|
||||
let pv = app.transform_preview.as_ref().unwrap();
|
||||
assert!(pv.several.as_ref().is_some_and(|sv| sv.ids == [a, b]), "the preview moves both");
|
||||
// The moving pixels hold B's blue block where B is, beside A's red one: B moves with the box
|
||||
// on the canvas, not only at the commit.
|
||||
assert_eq!(moving_texel(&app, [48, 16]).0, Some(Color32::BLUE), "the linked layer's pixels are in the preview");
|
||||
assert_eq!(moving_texel(&app, [16, 16]).0, Some(Color32::RED), "and the active layer's");
|
||||
assert_eq!(moving_texel(&app, [16, 48]).0, None, "the unlinked layer is outside the moving pixels");
|
||||
let t = app.ui.transform.as_mut().unwrap();
|
||||
t.quad = t.quad.map(|[x, y]| [x + 4.0, y]);
|
||||
commit(&mut app);
|
||||
assert!(app.ui.transform.is_none(), "{}", app.ui.status);
|
||||
let st = app.session.active().unwrap();
|
||||
let bounds = |id: LayerId| st.doc.layer(id).unwrap().surface().unwrap().content_bounds();
|
||||
assert_eq!(bounds(a), photocraft_geom::Rect::new(12, 8, 28, 24));
|
||||
assert_eq!(bounds(b), photocraft_geom::Rect::new(44, 8, 60, 24));
|
||||
assert_eq!(bounds(c), photocraft_geom::Rect::new(8, 40, 24, 56), "an unlinked layer stays put");
|
||||
}
|
||||
|
||||
/// #11217: a layer without linked layers still opens the one-layer box.
|
||||
#[test]
|
||||
fn free_transform_of_an_unlinked_layer_boxes_that_layer_alone() {
|
||||
let (mut app, _, _, c) = linked_layers_app();
|
||||
app.session.execute("layer.select", json!({"layer": c.0})).unwrap();
|
||||
begin(&mut app, &egui::Context::default()).unwrap();
|
||||
let t = app.ui.transform.as_ref().unwrap();
|
||||
assert_eq!((t.layer, t.rect), (c.0, [8.0, 40.0, 24.0, 56.0]));
|
||||
assert!(t.layers.is_empty(), "a one-layer session");
|
||||
assert!(app.transform_preview.as_ref().unwrap().several.is_none());
|
||||
}
|
||||
|
||||
/// #11217 review: a layer linked to the Background opens the one-layer box: the link doesn't pull
|
||||
/// the Background into the box, as the selection doesn't either (#6501).
|
||||
#[test]
|
||||
fn free_transform_of_a_layer_linked_to_the_background_boxes_that_layer_alone() {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
let bg = app.session.active().unwrap().doc.layers[0].id;
|
||||
app.session.execute("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(app.session.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||||
app.session.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
begin(&mut app, &egui::Context::default()).unwrap();
|
||||
let t = app.ui.transform.as_ref().unwrap();
|
||||
assert_eq!((t.layer, t.rect), (a.0, [8.0, 8.0, 24.0, 24.0]), "the box frames A, not the canvas-sized Background");
|
||||
assert!(t.layers.is_empty(), "a one-layer session");
|
||||
let pv = app.transform_preview.as_ref().unwrap();
|
||||
assert!(pv.several.is_none());
|
||||
assert!(pv.doc.layer(bg).unwrap().visible, "the Background stays under the box");
|
||||
}
|
||||
|
||||
/// #11217: a locked layer in the link group refuses the box before it opens, as a locked layer
|
||||
/// among several selected ones does.
|
||||
#[test]
|
||||
fn a_locked_linked_layer_refuses_the_box_over_its_link_group() {
|
||||
let (mut app, a, b, _) = linked_layers_app();
|
||||
app.session.execute("layer.setProps", json!({"layer": b.0, "locks": {"position": true}})).unwrap();
|
||||
app.session.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
assert_eq!(begin(&mut app, &egui::Context::default()), Err(LOCKED.to_string()));
|
||||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||||
}
|
||||
|
||||
/// #2439: a layer under others keeps its place in the stack while the box is open. The
|
||||
/// canvas under the box shows only the layers below it; the ones over it are composited on
|
||||
/// their own and drawn over the moving pixels.
|
||||
@@ -3165,6 +3281,26 @@ fn mask_view_keeps_move_and_transform_available_behind_a_colour_set() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch 16 review: ⌥⌘T with the unlinked mask of a linked layer targeted still copies; only the
|
||||
/// layer's own pixels take the link group's box without a copy.
|
||||
#[test]
|
||||
fn free_transform_a_copy_of_a_targeted_mask_on_a_linked_layer_still_copies() {
|
||||
let mut app = masked_app(false);
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
let b = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
app.session.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(app.session.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||||
app.session.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
app.ui.mask_target = true;
|
||||
let layers = app.session.active().unwrap().doc.layers.len();
|
||||
crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransformCopy", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the layer is copied");
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().made, Some(MadeLayer::Copy));
|
||||
cancel(&mut app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_mask_target_transforms_the_layer() {
|
||||
let mut app = masked_app(true);
|
||||
|
||||
@@ -174,11 +174,11 @@ fn editing_a_crop_frame_shows_the_pixels_past_the_canvas() {
|
||||
assert!(c[0] > 230 && c[1] < 40, "at x = {x}: {c:?}");
|
||||
}
|
||||
|
||||
// Esc cancels: back to the canvas alone.
|
||||
// Esc cancels: back to the canvas alone, with no frame until the next press (#10370).
|
||||
h.event(egui::Event::PointerGone);
|
||||
h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let img = render(&mut h);
|
||||
assert_eq!(h.state().ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0]));
|
||||
assert_eq!(h.state().ui.crop_rect, None, "Esc hides the frame");
|
||||
assert_eq!(shown(&h, &img), was, "hidden again after Esc");
|
||||
}
|
||||
+97
-4
@@ -18,6 +18,29 @@ layers and documents, newer and older events and files, and a measured reference
|
||||
map yet, with everything needed to implement them without
|
||||
the reference application.
|
||||
|
||||
## Action playback history
|
||||
|
||||
Actions create one history entry named `Action: <name>` by default. Nested plays
|
||||
share the outer action's entry, and a failing or Stop step keeps the completed
|
||||
prefix. A play with no document change adds no entry and preserves redo.
|
||||
`actions.setHistory {"singleState":false}` restores per-step history. The option
|
||||
is saved as `actions.createSingleHistoryState` in preferences and defaults to true
|
||||
when loading older preferences. It is separate from Playback Options' performance
|
||||
modes; the Playback Options dialog can use the same setting when #5212 lands.
|
||||
|
||||
Step-dialog confirmation joins the original play only while its document and
|
||||
history still match the paused state. Cancel keeps the completed prefix; an
|
||||
intervening edit or document switch prevents the stale dialog from applying.
|
||||
Batch and droplets group each processed document independently under the action's
|
||||
name and use the same saved option.
|
||||
Existing non-linear History ordering and visibility options continue to apply.
|
||||
|
||||
Explicit Clear History and Purge Histories steps keep their normal effects. Later
|
||||
edits form a grouped entry from that new boundary, without restoring discarded
|
||||
states. If an action explicitly chooses an intermediate row as the History Brush
|
||||
source, its pixels are retained as a named snapshot when grouping removes that
|
||||
row; the action still adds only one Undo entry.
|
||||
|
||||
## Supported events
|
||||
|
||||
| Code | String ID | Label | PhotoCraft command(s) |
|
||||
@@ -45,6 +68,7 @@ the reference application.
|
||||
| (string ID only) | collapseAllGroupsEvent | Collapse All Groups | `layer.setExpanded` |
|
||||
| `ClrB` | colorBalance | Color Balance | `image.adjustments.colorBalance` |
|
||||
| `ClrR` | colorRange | Color Range | `select.colorRange` |
|
||||
| (string ID only) | cafWorkspace | Content-Aware Fill | `edit.contentAwareFill` |
|
||||
| `Cntc` | contract | Contract | `select.modify.contract` |
|
||||
| `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` |
|
||||
| (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` |
|
||||
@@ -83,6 +107,7 @@ the reference application.
|
||||
| `Flip` | flip | Flip | `image.imageRotation.flipCanvasHorizontal`, `image.imageRotation.flipCanvasVertical`, `edit.transform.flipHorizontal`, `edit.transform.flipVertical` |
|
||||
| (string ID only) | focusMask | Focus Area | `select.focusArea` |
|
||||
| `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` |
|
||||
| `Grdn` | gradientClassEvent | Classic Gradient (custom RGB, centred midpoints) | `paint.gradient` |
|
||||
| (string ID only) | gradientMapEvent | Gradient Map | `image.adjustments.gradientMap` |
|
||||
| `GrMp` | (code only) | Gradient Map (recorded classic form) | `image.adjustments.gradientMap` |
|
||||
| `RplC` | replaceColor | Replace Color (Lab sample box) | `image.adjustments.replaceColor` |
|
||||
@@ -99,7 +124,7 @@ the reference application.
|
||||
| `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` |
|
||||
| `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` |
|
||||
| `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` |
|
||||
| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` |
|
||||
| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape`, `type.createWorkPath` |
|
||||
| (string ID only) | animationFramesFromLayers | Make Frames from Layers | |
|
||||
| (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` |
|
||||
| `Mxm ` | maximum | Maximum | `filter.other.maximum` |
|
||||
@@ -248,6 +273,38 @@ The separate `CropPhotos0001` helper remains a complete kept Photoshop step.
|
||||
Playback stops at that enabled helper before subsequent duplicate/crop steps can
|
||||
run. This covers the parent event, not complete recordings with generated helpers.
|
||||
|
||||
### Content-Aware Fill recordings
|
||||
|
||||
`cafWorkspace` maps Rectangular / Default color adaptation / None rotation with New Layer or
|
||||
Current Layer output to `edit.contentAwareFill`, retaining all seven keys and the three booleans:
|
||||
Sample All Layers, Scale and Mirror. The selection comes from the preceding selection step.
|
||||
New Layer is in the measured reference; Current Layer and the enumeration type IDs are explicit
|
||||
in [Stephen Marsh's published Action Manager example](https://community.adobe.com/questions-712/expand-and-content-aware-fill-functions-are-not-working-via-javascript-1172085).
|
||||
|
||||
| Recorded key, in order | Supported value | Native parameter |
|
||||
|---|---|---|
|
||||
| `cafSamplingRegion` | `cafSamplingRegionRectangular` | `sampling: "rectangular"` |
|
||||
| `cafSampleAllLayers` | boolean | `sampleAllLayers` |
|
||||
| `cafColorAdaptationLevel` | `cafColorAdaptationDefault` | `colorAdaptation: "default"` |
|
||||
| `cafRotationAmount` | `cafRotationAmountNone` | `rotationAdaptation: "none"` |
|
||||
| `cafScale` | boolean | `scale` |
|
||||
| `cafMirror` | boolean | `mirror` |
|
||||
| `cafOutput` | `cafOutputToNewLayer`, `cafOutputToCurrentLayer` | `output: "new"`, `"current"` |
|
||||
|
||||
Descriptor types, source identity and key order survive save while its native settings are
|
||||
unchanged. Editing a supported setting exports the new values in canonical form instead of
|
||||
restoring stale source values; malformed preservation data still refuses export.
|
||||
Native export writes the seven keys explicitly; the known native boolean, color
|
||||
adaptation, rotation and Current Layer output defaults are supported when sampling is explicitly
|
||||
Rectangular. A native call using the Auto sampling default cannot yet be exported.
|
||||
|
||||
The reference's ellipses do not establish additional enum forms. Auto/Custom sampling,
|
||||
other adaptation levels, Duplicate Layer output, absent keys, unknown settings and
|
||||
malformed or repeated fields remain complete kept steps. Native layer targets, sampling/fill
|
||||
edits and nondefault seeds also refuse export rather than lose their settings. Playback uses
|
||||
the existing native fill and its selection, authorization, job and Undo/Redo behavior. The
|
||||
remaining CAF forms and the other recorded tools in #10710 are still separate work.
|
||||
|
||||
### Layer Style menu recordings
|
||||
|
||||
`scaleEffectsEvent` maps to `layer.layerStyle.scaleEffects` with the recorded `Scl `
|
||||
@@ -269,6 +326,19 @@ metadata remains refused. These two rows are the implemented subset of #10723;
|
||||
the other menu recordings remain separate work. Playback reuses native history
|
||||
and rendering; this does not claim Photoshop pixel equivalence.
|
||||
|
||||
### Type Create Work Path recordings
|
||||
|
||||
Make (`Mk ` / `make`) maps to `type.createWorkPath` when `null` is the `Path`
|
||||
class reference and `From` is the target text layer (`TxLr.Ordn.Trgt`). Playback
|
||||
uses the existing native glyph-outline command and leaves the text layer editable.
|
||||
Undo restores the previous work path; Redo restores the new one.
|
||||
|
||||
The mapping preserves the complete descriptor, including key order and reference
|
||||
names. Unknown or duplicate keys, named sources, other source classes and other
|
||||
reference chains remain complete kept Photoshop steps. Native export supports
|
||||
the implicit active text layer only; explicit layer targets refuse export.
|
||||
This is another implemented subset of #10723, not support for its remaining events.
|
||||
|
||||
### Settings retained by mapped events
|
||||
|
||||
- Transform keeps its recorded `FTcs` reference point. Show with `TglO` true maps to
|
||||
@@ -440,6 +510,32 @@ differences that do not change what a step does.
|
||||
- Colours are kept as 8-bit values (51.0031 becomes 51); gradient names become "Custom".
|
||||
- Levels and Curves write the composite as `Chnl.Cmps` (the application skips `Ordn.Trgt`, #8543).
|
||||
|
||||
### Classic Gradient recordings
|
||||
|
||||
`Grdn` / `gradientClassEvent` maps to `paint.gradient` for custom RGB gradients
|
||||
with byte-valued colours and centred colour/transparency midpoints. The five
|
||||
measured styles are Linear, Radial, Angle, Reflected and Diamond. Pixel endpoints,
|
||||
opacity, blend mode, reverse, dither and the transparency switch are explicit;
|
||||
an omitted `Dthr` is off, rather than the native command's on default. The
|
||||
recorded interpolation method maps to the existing Classic, Perceptual, Linear
|
||||
or Smooth implementation, and `Intr` supplies smoothness.
|
||||
|
||||
Unchanged recordings retain their complete descriptor bytes, key order, ID
|
||||
encoding and explicit defaults. A supported edit writes the recorded key order:
|
||||
`From`, `T `, non-default `Opct` and `Md `, `Type`, non-default `Rvrs`, enabled
|
||||
`Dthr` and `UsMs`, `gradientsInterpolationMethod`, then required `Grad`. Source
|
||||
data is never replayed in place of edited executable settings. Native export
|
||||
requires explicit stops, transparency (an empty list means opaque) and method;
|
||||
it does not resolve a named or current gradient preset at save time.
|
||||
|
||||
Unknown/duplicate keys, absent interpolation methods, non-centred midpoints,
|
||||
fractional RGB colours, noise gradients and dynamic foreground/background stops
|
||||
remain kept steps. The current preset model does not represent those forms
|
||||
faithfully. Invalid units/enums or missing `Grad` also stay kept, rather than
|
||||
using a different gradient. The default Make/Set gradient fill-layer recordings
|
||||
are separate work. Synthetic fixtures follow the measured reference below;
|
||||
no new Photoshop recording or pixel-equivalence measurement is claimed.
|
||||
|
||||
## Reference forms
|
||||
|
||||
How the reference application resolves a layer or document a step names, measured on 2026-11-01 by
|
||||
@@ -554,7 +650,6 @@ layer reference, `Ordn.Trgt`); mapping them needs only the event name.
|
||||
| Layer > Smart Objects > Stack Mode > None | `imageStackConvertSmartObject` | none |
|
||||
| Layer > Video Layers > New Blank Video Layer, Insert Blank Frame, Delete Frame, Show / Hide Altered Video | `addBlankVideoLayer`, `insertBlankFrame`, `deleteFrame`, `showAlteredVideo` / `hideAlteredVideo` | none |
|
||||
| Layer > Video Layers > Interpret Footage | `interpretFootage` | `Vrsn` long (1), `interpretAlpha` (`alphaInterpretation`: `Ignr` ignore, …), `colorConvertAlterations` bool |
|
||||
| Type > Create Work Path | Make, `null` = class `Path`, `From` = text layer (`TxLr`) target | |
|
||||
| Edit > Find and Replace Text (Replace All) | `replace`, `null` = reference: property `replace` of all text layers (`TxLr` `Ordn.Al`), `Usng` = `find` text, `replace` text, `checkAll`, `Fwd `, `caseSensitive`, `wholeWord`, `ignoreAccents` bool | |
|
||||
| Edit > Check Spelling | `spellCheck`, `null` = property `spellCheck` of all text layers, `Usng` = `checkAll` bool | recorded with its dialog switched on; the corrections themselves are not recorded |
|
||||
| Image > Analysis > Set Measurement Scale > Custom | Set, `null` = property `measurementScale` of the document target, `T ` = `pixelLength` long, `logicalLength` double, `logicalUnits` text | |
|
||||
@@ -641,9 +736,7 @@ the recorded steps back from the saved Actions panel. Ranges come from the dialo
|
||||
| Convert Mode, several layers, "Don't Flatten" | `T ` as a **class** (`Grys`), `Fltt` false, `Rstr` false | With `Fltt` and `Rstr` the step plays without the flatten question. |
|
||||
| New Group with a colour | `Usng` (`Clr ` label), `layerSectionStart`, `layerSectionEnd`, top-level `Nm ` | |
|
||||
| Shear (`Shr `) | `ShrP` list of `Pnt ` (`Hrzn`, `Vrtc` doubles), `UndA` (`WrpA` wrap around, `RptE` repeat edge pixels), `ShrS` long, `ShrE` long | The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. `ShrS` is 0 and `ShrE` the last point's index (count - 1). Script runs take any value unclamped and crashed on far-off `ShrE`/`ShrS` or a first `Vrtc` below 1. |
|
||||
| Gradient tool, classic (`Grdn` / `gradientClassEvent`) | `From`, `T ` points in pixels; `Opct` percent, `Md ` blend mode, `Type` (`GrdT`: `Lnr `, `Rdl `, `Angl`, `Rflc`, `Dmnd`), `Rvrs` bool, `Dthr` bool, `UsMs` bool (transparency), `gradientsInterpolationMethod` (`Perc` perceptual, …), `Grad` gradient object (as in Gradient Map) | Recorded in that order. `Opct`, `Md ` and `Rvrs` appear only when they differ from 100 %, Normal and off; `Dthr` and `UsMs` only when on. The five types are the only accepted values. A step without `Grad` fails. |
|
||||
| Gradient tool, default mode | two steps: Make (`null` = class `contentLayer`, `Usng` = `Type` gradient layer object) then Set (`null` = `contentLayer` target, `T ` = the same object plus `Algn` false) | The object: `Dthr`, `gradientsInterpolationMethod`, `Angl` angle, `Type`, `Scl ` percent, `Ofst` (`Hrzn`, `Vrtc` percent), `Grad`. The new tool makes a gradient fill layer; Make holds the first click, Set the finished drag. |
|
||||
| Content-Aware Fill (`cafWorkspace`) | `cafSamplingRegion` (`cafSamplingRegionRectangular`, …), `cafSampleAllLayers` bool, `cafColorAdaptationLevel` (`cafColorAdaptationDefault`, …), `cafRotationAmount` (`cafRotationAmountNone`, …), `cafScale` bool, `cafMirror` bool, `cafOutput` (`cafOutputToNewLayer`, …) | Runs on the selection, which is recorded as its own step before it. |
|
||||
| Perspective Warp (`perspectiveWarpTransform`) | `null` = layer target, `Md ` (`perspectiveWarpMode`: `warp`), `referenceRect` rectangle in pixels, `vertices` list of points in pixels (the layout quad), `warpedVertices` (the same points after warping), `quads` list of `indices` (4 vertex indexes each) | Layout and warp are one step. |
|
||||
| Print (`print`) then Print One Copy (`printOneCopy`) | `print`: `null` = reference `printOutput` property of the target document; `printOutput` (`PstS`, `Inte`, `printSixteenBit`, `printerName` text, `printProofSetup`), `printViewProofSetup`, `printOutputOptions` (caption, marks, `Bckg` colour, `BrdT`, `Bld `, `Rslt`, `vectorData`, `PgPs`, `Left`, `Top `, `Scl `, crop rectangle), `osSpecificPrintInfo` raw data, `Cptn` text. `printOneCopy` has no keys. | Pressing Print in the dialog records both steps. |
|
||||
| Create Droplet (`CrtD`) | `In ` alias: the droplet file; `Fldr` bool (include subfolders); `Usng` reference to the action; then the Batch keys below (destination, file naming, error log) when set | The same settings as Batch, with the droplet file instead of a source folder. |
|
||||
|
||||
@@ -54,7 +54,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`.
|
||||
- `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`.
|
||||
- `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=<file.csv>` / `--stroke-trace <file.csv>`): the control channel doesn't write files outside the automation write root
|
||||
- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `color`, `smartObject` or `artboard` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is a blend mode name (`Multiply`, `Pass Through`); `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode and Color always filter, by Bevel & Emboss, by Normal and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Fields left out keep their value; a bad one is an error and changes nothing.
|
||||
- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or `artboard` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `attribute` (#10881) is `visible`, `locked`, `empty`, `linked`, `clipped`, `layerMask`, `vectorMask`, `layerEffects` or `advancedBlending`, or one of their opposites `notVisible`, `notLocked`, `notEmpty`, `notLinked`, `notClipped`, `noLayerMask`, `noVectorMask`, `noLayerEffects`, `noAdvancedBlending`; `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode, Attribute and Color always filter, by Bevel & Emboss, by Normal, by Visible and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Fields left out keep their value; a bad one is an error and changes nothing.
|
||||
- `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown.
|
||||
- Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back.
|
||||
- Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| [Performance](#performance) | 3 | 22 | 45 | 79 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 41 not run, 4 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 |
|
||||
| [Tools](#tools) | 36 | 9 | 0 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 186 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 187 (target 0) |
|
||||
| [Type](#type) | 5 | 5 | 0 | |
|
||||
| [Automation](#automation) | 5 | 3 | 3 | 640 of 640 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present |
|
||||
@@ -203,7 +203,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 10 of 186** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
**Settings that do nothing: 10 of 187** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`.
|
||||
|
||||
|
||||
Reference in new issue
Block a user