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:
committed 2026-11-02 03:03:10 +01:00
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 },
},
+17 -6
View File
@@ -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.
+12 -1
View File
@@ -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}");
}
}