mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 05:02:51 +00:00
Integration: keep Shift-click and Lmnc channel selects, record panel colour clicks by name (#10616)
- A Shift-click channel select (`slct` Chnl.Chnl.Cyn with `Extd` true) imported as a plain
single-channel `channel.target`, so playback targeted Cyan alone and Save dropped `Extd`.
It now stays a kept Photoshop step (only `Extd` absent or false maps).
- `select Chnl.Chnl.Lmnc` went through `channel_of`'s legacy fallback, targeted Lightness and
saved back as `Lght `. The select importer now uses the exact CHANNELS lookup, so it stays a
kept step and round-trips unchanged.
- The Channels panel recorded a colour click as `{"channel":{"color":0}}`, which the new export
rejects, so Save Actions of a PhotoCraft recording failed. A colour row click now sends the
mode's lowercase colour name (`{"color":"cyan"}`), which targets the same channel and exports
as the component select; other rows keep their reference.
Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25b7ac59f3
commit
cf30adf2a2
4 files changed
+96
-9
No files matched your search
@@ -6,7 +6,7 @@ use super::descriptors::{
|
||||
use super::events::pixel_target;
|
||||
use super::fields::{F, Kind, Mapped, fields_to, none, step, steps};
|
||||
use super::steps_make::is_tool_class;
|
||||
use super::tables::{CHANNELS, FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, channel_of, lookup, lookup_back};
|
||||
use super::tables::{CHANNELS, FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, lookup, lookup_back};
|
||||
use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json};
|
||||
|
||||
/// Translate supported selection targets, retaining unknown references as Photoshop steps.
|
||||
@@ -32,9 +32,12 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped {
|
||||
"Trgt" => Mapped::Unsupported { harmless: true },
|
||||
_ if r.len() == 1
|
||||
&& d.get("selectionModifier").is_none()
|
||||
// Shift-click (`Extd` true) adds the channel to the selection: kept verbatim.
|
||||
&& !matches!(d.get("Extd"), Some(v) if v != &D::Boolean(false))
|
||||
&& matches!(r.first(), Some(ReferenceItem::Enumerated { type_id, .. }) if type_id.is("Chnl")) =>
|
||||
{
|
||||
channel_of(&o).map_or(Mapped::Unsupported { harmless: false }, |channel| step("channel.target", json!({"channel": {"color": channel}})))
|
||||
// The exact code only: the legacy `Lmnc` would save back as `Lght`.
|
||||
lookup(CHANNELS, &o).map_or(Mapped::Unsupported { harmless: false }, |channel| step("channel.target", json!({"channel": {"color": channel}})))
|
||||
}
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
|
||||
@@ -3213,14 +3213,18 @@ fn select_color_components_preserve_unsupported_selection_shapes() {
|
||||
ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Cyn") },
|
||||
ReferenceItem::Identifier { class: class("Dcmn"), id: 7 },
|
||||
];
|
||||
for selection in [
|
||||
desc("null").with("null", channel_ord("Xxxx")),
|
||||
desc("null").with("null", channel_ref(Some("cyan"))).with("selectionModifier", enm("selectionModifierType", "addToSelection")),
|
||||
desc("null").with("null", D::Reference(chained)),
|
||||
desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Ordn"), value: code("Cyn") }])),
|
||||
for (mode, selection) in [
|
||||
("cmyk", desc("null").with("null", channel_ord("Xxxx"))),
|
||||
("cmyk", desc("null").with("null", channel_ref(Some("cyan"))).with("selectionModifier", enm("selectionModifierType", "addToSelection"))),
|
||||
("cmyk", desc("null").with("null", D::Reference(chained))),
|
||||
("cmyk", desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Ordn"), value: code("Cyn") }]))),
|
||||
// Shift-click adds the channel to the selection; playing it as a plain select would lose that.
|
||||
("cmyk", desc("null").with("null", channel_ref(Some("cyan"))).with("Extd", D::Boolean(true))),
|
||||
// The legacy `Lmnc` code would otherwise target Lightness and save back as `Lght`.
|
||||
("lab", desc("null").with("null", channel_ord("Lmnc"))),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2, "height": 1, "mode": "cmyk"})).unwrap();
|
||||
s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode})).unwrap();
|
||||
let data = set_of("Unknown", vec![("Pick", vec![item("select", "Select", Some(selection.clone()))])]);
|
||||
let loaded = s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1);
|
||||
@@ -3247,6 +3251,13 @@ fn select_color_components_preserve_unsupported_selection_shapes() {
|
||||
let gray = set_of("Gray", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ord("Gry"))))])]);
|
||||
let bytes = photocraft_paint::tile::b64_decode(&gray).unwrap();
|
||||
assert_eq!(import(&bytes).unwrap().actions[0].steps[0], ("channel.target".into(), json!({"channel": "composite"})));
|
||||
// A plain click (`Extd` false) is still an ordinary single-channel select.
|
||||
let plain = set_of(
|
||||
"Plain",
|
||||
vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ref(Some("cyan"))).with("Extd", D::Boolean(false))))])],
|
||||
);
|
||||
let bytes = photocraft_paint::tile::b64_decode(&plain).unwrap();
|
||||
assert_eq!(import(&bytes).unwrap().actions[0].steps[0], ("channel.target".into(), json!({"channel": {"color": "cyan"}})));
|
||||
}
|
||||
|
||||
/// A CMYK component cannot retarget a same-named alpha channel when replayed on RGB.
|
||||
|
||||
@@ -34,6 +34,17 @@ impl Row {
|
||||
Row::LayerMask => json!("mask"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `channel.target` reference a click records: a colour row by the mode's colour name,
|
||||
/// which a recorded action can save as Photoshop's component select (an index can't be).
|
||||
fn target_reference(self, mode: photocraft_doc::ColorMode) -> Value {
|
||||
let names = channel_cmds::color_names(mode);
|
||||
match self {
|
||||
Row::QuickMask => json!("composite"),
|
||||
Row::Color(k) if names.len() > 1 => names.get(k).map_or_else(|| self.reference(), |name| json!({ "color": name.to_ascii_lowercase() })),
|
||||
_ => self.reference(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌘ = new selection, ⌘⇧ add, ⌘⌥ subtract, ⌘⌥⇧ intersect (Photoshop's thumbnail ⌘-clicks).
|
||||
@@ -113,7 +124,7 @@ fn handle_channel_row_click(ui: &egui::Ui, row: &mut RowInteractions<'_>) {
|
||||
if matches!(row.row, Row::Composite | Row::Color(_)) {
|
||||
*row.mask_click = Some(false);
|
||||
}
|
||||
let target = if row.row == Row::QuickMask { json!("composite") } else { row.row.reference() };
|
||||
let target = row.row.target_reference(row.doc.pixel_format().mode);
|
||||
row.actions.push(("channel.target".into(), json!({ "channel": target, "extend": modifiers.shift && matches!(row.row, Row::Color(_)) })));
|
||||
}
|
||||
|
||||
|
||||
@@ -378,3 +378,65 @@ fn the_layer_mask_row_on_the_trash_asks_first() {
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(st.doc.layer(photocraft_doc::LayerId(masked)).unwrap().mask.is_some(), "nothing deleted yet");
|
||||
}
|
||||
|
||||
/// A colour channel click recorded into an action saves as Photoshop's component select and
|
||||
/// targets the same channel as the index did.
|
||||
#[test]
|
||||
fn recorded_colour_clicks_save_as_component_selects() {
|
||||
use photocraft_doc::ColorMode;
|
||||
for (mode, color_mode, count) in [("rgb", ColorMode::Rgb, 3), ("cmyk", ColorMode::Cmyk, 4), ("lab", ColorMode::Lab, 3)] {
|
||||
for k in 0..count {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode})).unwrap();
|
||||
let by_index = s.execute("channel.target", json!({"channel": Row::Color(k).reference(), "extend": false})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
|
||||
let params = json!({"channel": Row::Color(k).target_reference(color_mode), "extend": false});
|
||||
assert_eq!(s.execute("channel.target", params.clone()).unwrap(), by_index, "{mode}/{k}");
|
||||
s.actions.list.push(photocraft_engine::actions_cmds::Action::new("Click", vec![("channel.target".into(), params)]));
|
||||
let saved = s.execute("actions.save", json!({"action": "Click"})).unwrap_or_else(|e| panic!("{mode}/{k}: {e}"));
|
||||
assert_eq!(saved["skipped"], json!([]));
|
||||
let loaded = s.execute("actions.load", json!({"data": saved["data"]})).unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]), "{mode}/{k}");
|
||||
let name = photocraft_engine::channel_cmds::color_names(color_mode)[k].to_ascii_lowercase();
|
||||
assert_eq!(s.actions.list.last().unwrap().steps, vec![("channel.target".to_string(), json!({"channel": {"color": name}}))]);
|
||||
}
|
||||
}
|
||||
// Rows the colour names can't express keep their reference.
|
||||
assert_eq!(Row::Color(0).target_reference(ColorMode::Grayscale), Row::Color(0).reference());
|
||||
assert_eq!(Row::Alpha(1).target_reference(ColorMode::Rgb), json!(1));
|
||||
assert_eq!(Row::QuickMask.target_reference(ColorMode::Rgb), json!("composite"));
|
||||
}
|
||||
|
||||
/// A real click on a colour row while recording records the colour by name, and the recorded
|
||||
/// action saves strictly and reloads as the same step.
|
||||
#[test]
|
||||
fn recording_a_colour_row_click_saves_as_photoshop_select() {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
for (mode, label, name) in [("rgb", "Red", "red"), ("cmyk", "Cyan", "cyan"), ("lab", "Lightness", "lightness")] {
|
||||
let (mut h, _) = channels_harness();
|
||||
if mode != "rgb" {
|
||||
h.state_mut().run(&format!("image.mode.{mode}"), json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
}
|
||||
h.state_mut().run("actions.record", json!({"name": "Click"})).unwrap();
|
||||
let pos = at(&h, label);
|
||||
h.hover_at(pos);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("actions.stop", json!({})).unwrap();
|
||||
let s = &mut h.state_mut().session;
|
||||
let recorded = s.actions.list.iter().find(|a| a.name == "Click").unwrap().steps.clone();
|
||||
let step = ("channel.target".to_string(), json!({"channel": {"color": name}, "extend": false}));
|
||||
assert_eq!(recorded, vec![step], "{mode}");
|
||||
let saved = s.execute("actions.save", json!({"action": "Click"})).unwrap_or_else(|e| panic!("{mode}: {e}"));
|
||||
assert_eq!(saved["skipped"], json!([]), "{mode}");
|
||||
let loaded = s.execute("actions.load", json!({"data": saved["data"]})).unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]), "{mode}");
|
||||
let reloaded = &s.actions.list.last().unwrap().steps;
|
||||
assert_eq!(reloaded, &vec![("channel.target".to_string(), json!({"channel": {"color": name}}))], "{mode}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user