From ac92cafdfd8f7e6721853ec5cf63e775e4d338b4 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 5 Nov 2026 19:15:59 +0530 Subject: [PATCH] Keep each colour's values in the Selective Color dialog (#11463) Image > Adjustments > Selective Color used the generic schema form: one set of sliders shared by all nine colours, with OK applying them only to the colour selected at that moment. Switching colour looked like the values had spread, and edits to other colours were lost. Make it a dedicated adjustment kind with one editor shared by the dialog and the Properties panel. Each colour now keeps its own row, and OK and Preview apply all nine. The older single-colour params from actions and smart filters fold into the rows. --- .../src/dialogs/adjust_dialog/tests.rs | 247 +++++++++++++++++- crates/ui-egui/src/dialogs/adjust_editors.rs | 10 +- .../dialogs/adjust_editors/selective_color.rs | 99 +++++++ .../src/dialogs/adjust_editors/session.rs | 1 - .../src/dialogs/adjust_editors/ui_tests.rs | 31 +++ crates/ui-egui/src/dialogs/adjust_preview.rs | 4 +- .../src/dialogs/adjust_preview/tests.rs | 5 +- crates/ui-egui/src/dialogs/filter_dialog.rs | 1 - .../src/dialogs/filter_dialog/tests.rs | 14 - crates/ui-egui/src/panels/adjust_ui.rs | 75 +----- docs/ui-parity.md | 4 +- 11 files changed, 395 insertions(+), 96 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/adjust_editors/selective_color.rs diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index 2a6f6f5510..8674e97678 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -47,7 +47,7 @@ fn every_adjustment_with_an_editor_has_a_dialog() { for kind in adjust_editors::KINDS { assert!(has_dialog(&format!("image.adjustments.{kind}")), "{kind}"); } - for id in ["image.adjustments.invert", "image.adjustments.selectiveColor", "image.adjustments.desaturate", "filter.blur.gaussianBlur"] { + for id in ["image.adjustments.invert", "image.adjustments.desaturate", "filter.blur.gaussianBlur"] { assert!(!has_dialog(id), "{id}"); } } @@ -219,6 +219,7 @@ fn sample_params(kind: &str) -> Value { "posterize" => json!({"levels": 3}), "threshold" => json!({"level": 90}), "gradientMap" => json!({"stops": [[0, "#200040"], [1, "#ffd080"]]}), + "selectiveColor" => json!({"method": "absolute", "blues": [10, 0, -20, 0]}), _ => json!({}), } } @@ -967,3 +968,247 @@ fn captured_filter_mask_curves_stay_gray_after_selecting_native_channels() { assert!(add_point_at(&mut app, &egui::Context::default(), 8.0, 8.0, false)); assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before); } + +// --------------------------------------------------------------------------------------------- +// Selective Color: one editor for the dialog and the Properties panel. The Colors dropdown is +// driven here through the dialog's `colors` field (what it writes, and what `ui.dialog.set` +// sets), not by opening the list. + +const SELECTIVE: &str = "image.adjustments.selectiveColor"; + +/// A red left half and a blue right half, so each colour range has pixels to change. +fn selective_app() -> PhotocraftApp { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap(); + s.execute("edit.fill", json!({"color": "#c03020"})).unwrap(); + s.execute("select.rect", json!({"x": 32, "y": 0, "width": 32, "height": 48})).unwrap(); + s.execute("edit.fill", json!({"color": "#2030c0"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + PhotocraftApp::new(s, crate::Services::default()) +} + +fn selective_dialog() -> (Harness<'static, PhotocraftApp>, u64) { + let mut h = Harness::builder() + .with_size(egui::vec2(1200.0, 900.0)) + .with_step_dt(1.0 / 60.0) + .build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), selective_app()); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), SELECTIVE).unwrap(); + h.run_steps(3); + (h, id) +} + +fn set_field(h: &mut Harness<'static, PhotocraftApp>, id: u64, key: &str, value: Value) { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(key.into(), value); +} + +/// What the Cyan, Magenta, Yellow and Black sliders show. +fn slider_values(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.query_all_by_role(egui::accesskit::Role::Slider).filter_map(|n| n.accesskit_node().numeric_value()).collect() +} + +/// Press the `index`th slider in its middle and drag past its right (or left) end. +fn drag_slider(h: &mut Harness<'static, PhotocraftApp>, index: usize, right: bool) { + let r = h.query_all_by_role(egui::accesskit::Role::Slider).nth(index).expect("slider").rect(); + let from = r.center(); + let to = egui::pos2(if right { r.right() + 40.0 } else { r.left() - 40.0 }, from.y); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for i in 1..=6 { + h.hover_at(from + (to - from) * (i as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); +} + +fn pixel(app: &PhotocraftApp, x: usize, y: usize) -> [f32; 4] { + let st = app.session.active().unwrap(); + photocraft_compose::render(&st.doc, st.doc.bounds()).px[y * 64 + x] +} + +fn row_is_zero(v: &Value) -> bool { + v.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(|x| x.as_f64() == Some(0.0))) +} + +/// The reported bug (#11463), through the real dropdown: choosing another colour shows that +/// colour's own values instead of carrying the sliders over, and keeps the first colour's edit. +#[test] +fn selective_color_colors_dropdown_shows_each_colours_own_values() { + let (mut h, id) = selective_dialog(); + set_field(&mut h, id, "reds", json!([30.0, 0.0, 0.0, 0.0])); + h.run_steps(3); + assert_eq!(slider_values(&h), [30.0, 0.0, 0.0, 0.0]); + let named = |n: &egui_kittest::Node<'_>| { + let a = n.accesskit_node(); + a.value().as_deref() == Some("Reds") || a.label().as_deref() == Some("Reds") + }; + let at = h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| named(n)).expect("the Colors dropdown").rect().center(); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(3); + let blues = h.query_by_label("Blues").expect("the list opened on the press").rect().center(); + for k in 1..=6 { + h.hover_at(at + (blues - at) * (k as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(blues); + h.run_steps(3); + assert_eq!(field(&h, id, "colors"), json!("blues")); + assert_eq!(slider_values(&h), [0.0; 4], "Blues shows its own row"); + assert_eq!(field(&h, id, "reds"), json!([30.0, 0.0, 0.0, 0.0]), "Reds keeps its edit"); +} + +#[test] +fn selective_color_dialog_edits_one_colour_at_a_time() { + let (mut h, id) = selective_dialog(); + let steps = h.state().session.active().unwrap().history.past_len(); + assert_eq!(slider_values(&h), [0.0; 4]); + assert_eq!(field(&h, id, "colors"), Value::Null, "opens on Reds"); + drag_slider(&mut h, 0, true); + let reds = field(&h, id, "reds"); + let red_cyan = reds[0].as_f64().unwrap(); + assert!(red_cyan > 0.0, "{reds}"); + assert_eq!(slider_values(&h)[0], red_cyan); + // Another colour: its own row, still at 0; Reds keep theirs. + set_field(&mut h, id, "colors", json!("blues")); + h.run_steps(3); + assert_eq!(slider_values(&h), [0.0; 4], "the sliders show the Blues row"); + assert_eq!(field(&h, id, "reds"), reds); + assert_eq!(field(&h, id, "colors"), json!("blues"), "the editor keeps the colour field"); + drag_slider(&mut h, 0, false); + assert!(field(&h, id, "blues")[0].as_f64().unwrap() < 0.0); + assert_eq!(field(&h, id, "reds"), reds, "editing Blues leaves Reds alone"); + // Back to Reds: intact. + set_field(&mut h, id, "colors", json!("reds")); + h.run_steps(3); + assert_eq!(slider_values(&h)[0], red_cyan); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "nothing is committed before OK"); +} + +#[test] +fn selective_color_dialog_ok_applies_every_colour_in_one_step() { + let (mut h, id) = selective_dialog(); + let (before_red, before_blue) = (pixel(h.state(), 8, 8), pixel(h.state(), 56, 8)); + let steps = h.state().session.active().unwrap().history.past_len(); + set_field(&mut h, id, "method", json!("absolute")); + set_field(&mut h, id, "reds", json!([0.0, 0.0, 0.0, 60.0])); + set_field(&mut h, id, "blues", json!([0.0, 0.0, 0.0, 60.0])); + set_field(&mut h, id, "colors", json!("blues")); + h.run_steps(3); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let app = h.state(); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "one history step"); + let (cmd, params) = app.session.journal.last().unwrap(); + assert_eq!(cmd, SELECTIVE); + for key in photocraft_engine::adjust_cmds::RANGES { + assert!(params[key].is_array(), "{key} in {params}"); + } + assert_eq!(params["reds"][3].as_f64(), Some(60.0)); + assert_eq!(params["blues"][3].as_f64(), Some(60.0)); + assert_ne!(pixel(app, 8, 8), before_red, "Reds changed"); + assert_ne!(pixel(app, 56, 8), before_blue, "Blues changed"); +} + +#[test] +fn selective_color_dialog_cancel_leaves_the_document_untouched() { + let (mut h, id) = selective_dialog(); + drag_slider(&mut h, 0, true); + set_field(&mut h, id, "blues", json!([0.0, 0.0, 0.0, 60.0])); + h.run_steps(2); + let before = h.state().session.active().unwrap().doc.clone(); + h.state_mut().ui.close_dialog(id); + h.run_steps(2); + assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); +} + +#[test] +fn selective_color_reset_zeroes_every_colour_and_returns_to_reds() { + let (mut h, id) = selective_dialog(); + set_field(&mut h, id, "reds", json!([10.0, 0.0, 0.0, 0.0])); + set_field(&mut h, id, "blues", json!([0.0, 20.0, 0.0, 0.0])); + set_field(&mut h, id, "method", json!("absolute")); + set_field(&mut h, id, "colors", json!("blues")); + h.run_steps(2); + let mut fields = h.state().ui.dialogs.last().unwrap().fields.clone(); + reset(h.state(), &h.ctx, &mut fields); + assert!(!fields.contains_key("colors"), "back to Reds"); + for key in photocraft_engine::adjust_cmds::RANGES { + assert!(row_is_zero(&fields[key]), "{key}: {}", fields[key]); + } + assert_eq!(fields["method"], json!("relative")); +} + +#[test] +fn selective_color_dialog_folds_the_single_colour_form_into_the_rows() { + let (mut h, id) = selective_dialog(); + set_field(&mut h, id, "colors", json!("blues")); + set_field(&mut h, id, "cyan", json!(30)); + h.run_steps(3); + for key in ["cyan", "magenta", "yellow", "black"] { + assert_eq!(field(&h, id, key), Value::Null, "{key} folded"); + } + assert_eq!(field(&h, id, "blues")[0].as_f64(), Some(30.0)); + assert_eq!(field(&h, id, "colors"), json!("blues")); + assert_eq!(slider_values(&h)[0], 30.0); + // A later slider edit is not overridden by the old scalars. + drag_slider(&mut h, 0, false); + assert!(field(&h, id, "blues")[0].as_f64().unwrap() < 30.0); +} + +#[test] +fn selective_color_open_last_restores_every_row_and_the_method() { + let mut app = selective_app(); + let id = open(&mut app, SELECTIVE).unwrap(); + let dialog = app.ui.dialog_mut(id).unwrap(); + dialog.fields.insert("method".into(), json!("absolute")); + dialog.fields.insert("reds".into(), json!([10.0, -20.0, 30.0, 5.0])); + dialog.fields.insert("blues".into(), json!([-15.0, 0.0, 25.0, 0.0])); + dialog.fields.insert("colors".into(), json!("blues")); + let fields = dialog.fields.clone(); + remember(&mut app, &fields); + app.ui.dialogs.clear(); + let id = open_last(&mut app, SELECTIVE).unwrap(); + let restored = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(restored["method"], json!("absolute")); + assert_eq!(restored["reds"], json!([10.0, -20.0, 30.0, 5.0])); + assert_eq!(restored["blues"], json!([-15.0, 0.0, 25.0, 0.0])); + assert!(!restored.contains_key("colors"), "reopens on Reds"); +} + +#[test] +fn selective_color_open_last_reads_an_old_single_colour_pref() { + let mut app = selective_app(); + app.session.prefs.edit(|prefs| prefs.dialogs.insert(SELECTIVE.into(), json!({"colors": "blues", "cyan": 30}))); + let id = open_last(&mut app, SELECTIVE).unwrap(); + let restored = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(restored["blues"][0].as_f64(), Some(30.0)); + assert!(!restored.contains_key("cyan")); +} + +#[test] +fn selective_color_editor_survives_malformed_params() { + let (mut h, id) = selective_dialog(); + for colors in [json!(3), json!("purples"), json!(null), json!(["reds"])] { + set_field(&mut h, id, "colors", colors); + h.run_steps(2); + assert_eq!(slider_values(&h), [0.0; 4]); + } + set_field(&mut h, id, "cyan", json!("not a number")); + h.run_steps(2); + set_field(&mut h, id, "cyan", Value::Null); + set_field(&mut h, id, "reds", json!("oops")); + h.run_steps(2); + set_field(&mut h, id, "blues", json!([1, "x"])); + set_field(&mut h, id, "colors", json!("blues")); + h.run_steps(2); + assert_eq!(slider_values(&h), [1.0, 0.0, 0.0, 0.0], "a malformed row reads as zeros where it is not numbers"); + drag_slider(&mut h, 1, true); + let row = field(&h, id, "blues"); + assert!(row.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(Value::is_number)), "rebuilt as four numbers: {row}"); + assert!(row[1].as_f64().unwrap() > 0.0); +} diff --git a/crates/ui-egui/src/dialogs/adjust_editors.rs b/crates/ui-egui/src/dialogs/adjust_editors.rs index c2c503d05c..ca87b090ff 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors.rs @@ -4,7 +4,7 @@ //! Every editor edits the kind's complete parameter set (`photocraft_engine::adjust_params`) as a //! JSON object and reports an [`Edit`]: `changed` while values move (the host previews them live) //! and `commit` when a gesture ends (the Properties host then runs one `layer.setAdjustment`; a -//! dialog commits on OK). Selective Color and Color Lookup keep their editors in `adjust_ui`. +//! dialog commits on OK). Color Lookup keeps its editor in `adjust_ui`. use crate::point_curve as curve_edit; use crate::state::CurvesEditorState as CurveUi; @@ -28,6 +28,7 @@ mod curves; mod gradient_map; mod hue_saturation; mod levels; +mod selective_color; mod session; #[cfg(test)] @@ -48,10 +49,11 @@ use hue_saturation::hue_saturation; use hue_saturation::{lightness_track, move_hue_range_handle, saturation_track}; use levels::levels; pub use levels::{auto_levels, gamma_from_pos, gamma_pos}; +use selective_color::selective_color; pub use session::{layer_editor, layer_mem, properties_coalesce, properties_reset, session_start}; -/// Kinds edited here (Selective Color and Color Lookup have their own editors; Invert has none). -pub const KINDS: [&str; 13] = [ +/// Kinds edited here (Color Lookup has its own editor in `adjust_ui`; Invert has none). +pub const KINDS: [&str; 14] = [ "brightnessContrast", "levels", "curves", @@ -65,6 +67,7 @@ pub const KINDS: [&str; 13] = [ "posterize", "threshold", "gradientMap", + "selectiveColor", ]; pub fn has_editor(kind: &str) -> bool { @@ -161,6 +164,7 @@ pub fn editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Ed "posterize" => sliders(ui, v, &[("levels", "Levels", 2.0, 255.0, 4.0, "", None)]), "threshold" => threshold(ui, v, cx), "gradientMap" => gradient_map(ui, v, cx), + "selectiveColor" => selective_color(ui, v, cx), _ => Edit::default(), } } diff --git a/crates/ui-egui/src/dialogs/adjust_editors/selective_color.rs b/crates/ui-egui/src/dialogs/adjust_editors/selective_color.rs new file mode 100644 index 0000000000..54471cc771 --- /dev/null +++ b/crates/ui-egui/src/dialogs/adjust_editors/selective_color.rs @@ -0,0 +1,99 @@ +use photocraft_engine::adjust_cmds::RANGES; + +use super::{Edit, EditorCx, RichText, Tokens, Value, adjust_params, json, nums, widgets}; + +// --------------------------------------------------------------------------------------------- +// Selective Color + +/// The nine colours, in [`RANGES`] order. +const RANGE_LABELS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"]; + +/// The single-colour scalar form of the params (`"colors":"blues","cyan":30`), which this editor +/// folds into the per-colour rows. +const SCALARS: [&str; 4] = ["cyan", "magenta", "yellow", "black"]; + +/// The colour the editor shows. The dialog keeps it in the `"colors"` field (so automation can +/// drive it with `ui.dialog.set`); the Properties panel keeps it in egui memory, because its +/// params go to `layer.setAdjustment`. +fn selected_range(ui: &egui::Ui, v: &Value, cx: &EditorCx) -> usize { + let range = if cx.dialog { + v.get("colors").and_then(Value::as_str).and_then(|c| RANGES.iter().position(|r| *r == c)).unwrap_or(0) + } else { + ui.data(|d| d.get_temp::(cx.mem.with("selc-range"))).unwrap_or(0) + }; + range.min(RANGES.len() - 1) +} + +/// Selective Color: a colour dropdown, the Cyan/Magenta/Yellow/Black sliders of that colour's row, +/// and Relative/Absolute. Changing the colour is no edit of the values. +pub(super) fn selective_color(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { + let t = Tokens::get(ui.ctx()); + let mut e = Edit::default(); + // Params in the older single-colour form move into the rows; unparsable ones stay as they + // are, for the command to report on OK. + if SCALARS.iter().any(|k| v.get(*k).is_some()) + && let Ok(adj) = adjust_params::from_params("selectiveColor", &*v, None, photocraft_doc::ColorMode::Rgb) + { + let mut folded = adjust_params::to_params(&adj); + if cx.dialog + && let Some(colors) = v.get("colors").cloned() + && let Some(o) = folded.as_object_mut() + { + o.insert("colors".into(), colors); + } + *v = folded; + e.changed = true; + } + let range = selected_range(ui, v, cx); + let mut picked = range; + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Colors")).color(t.text_dim)); + let opts: Vec<(usize, &str)> = RANGE_LABELS.iter().copied().enumerate().collect(); + widgets::dropdown(ui, &format!("selc-colors-{:x}", cx.mem.value()), tl!("Color"), &mut picked, &opts, 150.0); + }); + if picked != range { + let picked = picked.min(RANGES.len() - 1); + if cx.dialog { + if let (Some(o), Some(name)) = (v.as_object_mut(), RANGES.get(picked)) { + o.insert("colors".into(), json!(name)); + e.changed = true; + } + } else { + ui.data_mut(|d| d.insert_temp(cx.mem.with("selc-range"), picked)); + } + } + let range = picked.min(RANGES.len() - 1); + let key = RANGES.get(range).copied().unwrap_or("reds"); + ui.add_space(4.0); + let mut row = nums(v, key, [0.0; 4]); + let mut moved = false; + for (k, text) in [tl!("Cyan"), tl!("Magenta"), tl!("Yellow"), tl!("Black")].into_iter().enumerate() { + let mut x = row.get(k).copied().unwrap_or(0.0).clamp(-100.0, 100.0); + let r = widgets::slider_row(ui, text, &mut x, -100.0..=100.0, "%", None); + if r.changed() + && let Some(slot) = row.get_mut(k) + { + *slot = x.round(); + moved = true; + } + e.add(Edit::of(&r)); + } + if moved && let Some(o) = v.as_object_mut() { + // A whole new row, so a missing or malformed one becomes four numbers. + o.insert(key.into(), json!(row)); + } + ui.add_space(4.0); + let method = v.get("method").and_then(Value::as_str).unwrap_or("relative").to_string(); + ui.horizontal(|ui| { + for (m, text) in [("relative", tl!("Relative")), ("absolute", tl!("Absolute"))] { + if ui.radio(method == m, text).clicked() + && method != m + && let Some(o) = v.as_object_mut() + { + o.insert("method".into(), json!(m)); + e.add(Edit::discrete(true)); + } + } + }); + e +} diff --git a/crates/ui-egui/src/dialogs/adjust_editors/session.rs b/crates/ui-egui/src/dialogs/adjust_editors/session.rs index 12eb73b470..d254b49bba 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/session.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/session.rs @@ -85,7 +85,6 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj let t = Tokens::get(ui.ctx()); let kind = photocraft_engine::commands::adjustment_kind(adj); match adj { - Adjustment::SelectiveColor { .. } => return crate::adjust_ui::selective_color_editor(app, ui, id, adj), Adjustment::ColorLookup { .. } => return crate::adjust_ui::color_lookup_editor(app, ui, id, adj), Adjustment::Invert => { ui.label(RichText::new(tl!("Invert has no settings.")).color(t.text_faint)); diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index f41fe4eca0..f3951615f5 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -995,3 +995,34 @@ fn gradient_map_stop_picker_closed_without_a_colour_changes_nothing() { assert!(h.state().live_adjust.is_none()); } } + +/// Selective Color in Properties: the Colors dropdown's choice lives in editor memory, so +/// switching colour commits nothing, and each colour keeps its own row. +#[test] +fn selective_color_properties_switch_colour_without_committing() { + let mut h = app_harness("selectiveColor", "rgb", 8); + let (id, before) = layer(&h); + let steps = h.state().session.active().unwrap().history.past_len(); + let nudge = |h: &mut Harness<'static, PhotocraftApp>| { + let r = label_rect(h, "Cyan").expect("Cyan label"); + let y = r.bottom() + 14.0; + drag(h, Pos2::new(r.left() + 40.0, y), Pos2::new(r.left() + 120.0, y)); + }; + nudge(&mut h); + let (_, reds) = layer(&h); + assert_ne!(reds, before); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + // Switch to Blues. + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("adjust-layer", id.0)).with("selc-range"), 4usize)); + h.run_steps(4); + assert_eq!(layer(&h).1, reds, "switching colour changes nothing"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + assert!(h.state().live_adjust.is_none()); + nudge(&mut h); + let (Adjustment::SelectiveColor { adjustments: first, .. }, (_, Adjustment::SelectiveColor { adjustments: now, .. })) = (&reds, layer(&h)) else { + panic!("Selective Color layer") + }; + assert_eq!(now[0], first[0], "Reds untouched by editing Blues"); + assert_ne!(now[4], [0.0; 4], "Blues edited"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 2); +} diff --git a/crates/ui-egui/src/dialogs/adjust_preview.rs b/crates/ui-egui/src/dialogs/adjust_preview.rs index 5d060f2c6a..60e52bf47a 100644 --- a/crates/ui-egui/src/dialogs/adjust_preview.rs +++ b/crates/ui-egui/src/dialogs/adjust_preview.rs @@ -30,8 +30,8 @@ //! RGB, adjusts and converts back (CMYK even changes when the settings are neutral), which a //! composite in display RGB can't reproduce; //! - in Grayscale documents, kinds that make colour from gray (Vibrance, Color Balance, Black & -//! White, Photo Filter, Channel Mixer, Gradient Map, colorized Hue/Saturation): the command -//! folds each pixel back to gray, the composite would carry the colour through; +//! White, Photo Filter, Channel Mixer, Gradient Map, Selective Color, colorized Hue/Saturation): +//! the command folds each pixel back to gray, the composite would carry the colour through; //! - on the GPU canvas, documents the wgpu compositor doesn't cover (it would fall back to a //! full CPU composite per change); on the CPU canvas, documents above the proxy size. diff --git a/crates/ui-egui/src/dialogs/adjust_preview/tests.rs b/crates/ui-egui/src/dialogs/adjust_preview/tests.rs index c83966066c..40418c202b 100644 --- a/crates/ui-egui/src/dialogs/adjust_preview/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_preview/tests.rs @@ -3,7 +3,7 @@ use photocraft_color::{BlendMode, Color, PixelFormat, SampleType}; use photocraft_doc::Size; use serde_json::json; -const KINDS: [&str; 13] = crate::adjust_editors::KINDS; +const KINDS: [&str; 14] = crate::adjust_editors::KINDS; /// Settings that move every kind well away from neutral. pub(crate) fn sample(kind: &str) -> Value { @@ -21,6 +21,7 @@ pub(crate) fn sample(kind: &str) -> Value { "posterize" => json!({"levels": 3}), "threshold" => json!({"level": 90}), "gradientMap" => json!({"stops": [[0, "#200040"], [1, "#ffd080"]]}), + "selectiveColor" => json!({"method": "absolute", "reds": [10, -20, 30, 5], "blues": [-15, 0, 25, 0]}), _ => json!({}), } } @@ -105,7 +106,7 @@ pub(crate) fn max_diff(a: &Document, b: &Document) -> f32 { } /// Kinds that make colour from gray: grayscale documents preview them on the CPU proxy. -const GRAY_COLOUR: [&str; 6] = ["vibrance", "colorBalance", "blackWhite", "photoFilter", "channelMixer", "gradientMap"]; +const GRAY_COLOUR: [&str; 7] = ["vibrance", "colorBalance", "blackWhite", "photoFilter", "channelMixer", "gradientMap", "selectiveColor"]; #[test] fn preview_composite_matches_the_destructive_command() { diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index ca448b9bf4..be53034183 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -306,7 +306,6 @@ pub fn preview_factor(command: &str, params: &Value, k: u32) -> u32 { /// Commands outside `filter.*` that get the schema dialog *with* live preview. pub const PREVIEWED: &[&str] = &[ - "image.adjustments.selectiveColor", "image.adjustments.colorLookup", "image.adjustments.shadowsHighlights", "image.adjustments.replaceColor", diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index dcaa32ddbd..036b085882 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -905,20 +905,6 @@ fn notes_after_a_spec_are_not_parameters() { } } -#[test] -fn selective_color_dialog_ok_applies() { - // With the junk `reds: 0` the dialog's OK failed (`reds` must be an array of 4 numbers). - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); - let id = open(&mut app, "image.adjustments.selectiveColor").unwrap(); - let fields = app.ui.dialogs.last().unwrap().fields.clone(); - assert!(fields.keys().all(|k| k.starts_with("__") || k.chars().all(|ch| ch.is_ascii_alphanumeric())), "{fields:?}"); - assert!(!fields.contains_key("reds"), "the per-range arrays are not dialog fields"); - app.ui.dialog_mut(id).unwrap().fields.insert("black".into(), json!(40.0)); - crate::dialogs::confirm(&mut app, id).unwrap(); - assert_eq!(app.session.journal.last().map(|j| j.0.as_str()), Some("image.adjustments.selectiveColor")); -} - /// A filtered test image with structure at every scale (low-frequency waves plus a ramp). fn waves(n: u32) -> Document { let mut doc = Document::with_background( diff --git a/crates/ui-egui/src/panels/adjust_ui.rs b/crates/ui-egui/src/panels/adjust_ui.rs index 2d70592c52..1074cbfd24 100644 --- a/crates/ui-egui/src/panels/adjust_ui.rs +++ b/crates/ui-egui/src/panels/adjust_ui.rs @@ -1,85 +1,18 @@ -//! Properties-panel editors for the Selective Color and Color Lookup adjustment layers. +//! Properties-panel editor for the Color Lookup adjustment layer. //! -//! Both commit through `layer.setAdjustment` (which merges these kinds' params into the current -//! values). Selective Color previews live while a slider drags, via `app.live_adjust` carrying -//! the full parameter set; Color Lookup commits on every change (its table isn't in the params). +//! It commits through `layer.setAdjustment` (which merges the params into the current values) on +//! every change, since its table isn't in the params. The other kinds, Selective Color included, +//! are edited by `adjust_editors`. use std::sync::{Arc, Mutex, PoisonError}; use photocraft_doc::{Adjustment, LayerId}; -use photocraft_engine::adjust_cmds::RANGES; use serde_json::{Value, json}; use crate::PhotocraftApp; use crate::theme::Tokens; use crate::widgets; -const RANGE_LABELS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"]; - -/// The full parameter set of a Selective Color adjustment (every range as `[c, m, y, k]`). -pub fn selective_values(adj: &Adjustment) -> Value { - let Adjustment::SelectiveColor { relative, adjustments } = adj else { - return json!({}); - }; - let mut v = json!({"method": if *relative { "relative" } else { "absolute" }}); - for (i, key) in RANGES.iter().enumerate() { - v[*key] = json!(adjustments[i]); - } - v -} - -pub fn selective_color_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &Adjustment) { - let t = Tokens::get(ui.ctx()); - let committed = selective_values(adj); - let mut values = match &app.live_adjust { - Some((l, v)) if *l == id && v.get("reds").is_some() => v.clone(), - _ => committed, - }; - let key = egui::Id::unique(("selc-range", id.0)); - let mut range: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Colors")).color(t.text_dim)); - let opts: Vec<(usize, &str)> = RANGE_LABELS.iter().copied().enumerate().collect(); - if widgets::dropdown(ui, &format!("selc-colors-{}", id.0), tl!("Color"), &mut range, &opts, 150.0) { - ui.data_mut(|d| d.insert_temp(key, range)); - } - }); - ui.add_space(4.0); - let mut live = false; - let mut commit = false; - for (k, label) in [tl!("Cyan"), tl!("Magenta"), tl!("Yellow"), tl!("Black")].iter().enumerate() { - let mut v = values[RANGES[range]][k].as_f64().unwrap_or(0.0) as f32; - let r = widgets::slider_row(ui, label, &mut v, -100.0..=100.0, "%", None); - if r.changed() { - values[RANGES[range]][k] = json!(v.round()); - live = true; - } - if r.drag_stopped() || (r.changed() && !r.dragged()) { - commit = true; - } - } - ui.add_space(4.0); - let mut method = values["method"].as_str().unwrap_or("relative").to_string(); - ui.horizontal(|ui| { - for (m, label) in [("relative", tl!("Relative")), ("absolute", tl!("Absolute"))] { - if ui.radio(method == m, label).clicked() && method != m { - method = m.to_string(); - values["method"] = json!(m); - commit = true; - } - } - }); - if live { - app.live_adjust = Some((id, values.clone())); - } - if commit { - let mut p = values; - p["layer"] = json!(id.0); - let _ = app.run("layer.setAdjustment", p); - app.live_adjust = None; - } -} - /// Ask the platform for one LUT. Send embedded text through the same engine command on /// desktop and web, so the result doesn't depend on ambient filesystem permissions. fn browse_color_lookup(app: &mut PhotocraftApp, layer: LayerId) -> Result { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 80664d314b..c81ceb25d1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -221,6 +221,7 @@ for partial sets; [scope, tests and visual evidence](multi-channel-editing.md). | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | | Paragraph box overflow mark: text past the box shows a + in its lower-right handle | done (2026-10-29): text a box can't hold (dropped layout lines) enlarges the box's lower-right handle and shows a black + in it, as the reference application does; agents read `typeBoxOverflow` in `ui.inspect` (false once the edit session ends). Differs: shown while the inline edit session is on the box, and not while its transform handles are up | `text::TextLayout::overflows`, `canvas_tests.rs` `an_overflowing_box_shows_a_plus_in_its_enlarged_lower_right_handle` | +| Image › Adjustments › Selective Color: each of the nine colours keeps its own Cyan, Magenta, Yellow and Black; choosing another colour shows its values; OK and Preview apply all nine | done (#11463): one editor shared with the Properties panel (`adjust_editors/selective_color.rs`, `adjust_dialog/tests.rs`, 2026-11-05). Preset menu and `.asv` files not yet | | | ⌥ + an adjustment's shortcut opens it at its last settings (⌥⌘L Levels, ⌥⌘M Curves, and so ⌥⌘U, ⌥⌘B) | done (#8900): the settings of the last OK, kept in the preferences; Cancel and smart filter edits keep nothing; a key assigned to another command stays that command's. ⌥ while choosing the item from Image › Adjustments does the same | `shortcut_dispatch/tests.rs` `alt_adjustment_shortcut_reopens_the_last_confirmed_settings`, `alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings` | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | | Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush, Pencil and Paint Bucket only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. While a Free Transform box is open they, and the number keys, still reach the tool (or the layers) without closing it; Puppet Warp and Perspective Warp keep them to themselves. Clone Stamp, Pattern Stamp, History Brush, Healing (its own short list) and Paint Bucket (with Clear) take them as their own mode; tools without a blend mode (the Erasers, Mixer Brush) set the layers. Differs: Behind (⇧⌥Q) is not implemented; Spot Healing, Color Replacement, Blur, Sharpen and Smudge ignore the keys until their options bar has a Mode, and Dodge, Burn and Sponge ignore them until their Range and mode keys (⇧⌥S/M/H, ⇧⌥D/S) exist | `blend_keys.rs` | @@ -268,6 +269,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Image › Adjustments › Selective Color keeps each colour's own Cyan, Magenta, Yellow and Black, and OK and Preview apply all nine colours; the dialog and the Properties panel share one editor (#11463) | | 2026-11-02 | minor | Edit › Transform › Distort: the side handles move their edge freely, both its corners by the drag, stopping before the box folds as a corner does; Perspective's side handles skew along their edge, as in Photoshop (#10085). Not a new command, so no percentage changed | | 2026-11-01 | minor | Every preference takes effect: 11 of 183 do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see | | 2026-10-31 | minor | Layers panel filter-type dropdown: Color filters by the layers' label colour (#5898); Kind, Name, Mode, Color, Smart Object and Artboard, the other three missing; no percentage changed |