Gradient Map: pick presets from the gradient library (#1780)

The Gradient Map preset picker lists the session's gradient library by group with a swatch per preset, in the Properties panel and the dialog, as Photoshop's gradient picker does. Picking one writes its colour stops as one undoable edit.
Fixes #1588.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Mann 2026-10-09 15:09:51 +02:00 committed by GitHub
parent 616f206c5d
commit 347a0f9fc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 80 additions and 16 deletions

View File

@ -182,7 +182,14 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map<String,
Some(id) if adjust_editors::needs_histogram(&kind) => Some(tone::histograms(app, HistSource::Layer(id), adjust_editors::space_of(&values))),
_ => None,
};
let cx = EditorCx { mem: dialog_mem(&kind), hist, gray, swatches: adjust_editors::swatches(app), dialog: true };
let cx = EditorCx {
mem: dialog_mem(&kind),
hist,
gray,
swatches: adjust_editors::swatches(app),
gradients: adjust_editors::gradient_presets(app, &kind),
dialog: true,
};
let e = adjust_editors::editor(ui, &kind, &mut values, &cx);
if e.changed
&& let Value::Object(v) = values

View File

@ -14,6 +14,8 @@ use egui::{Color32, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}
use photocraft_doc::adjust::{HueRange, ToneSpace};
use photocraft_doc::{Adjustment, LayerId};
use photocraft_engine::adjust_params::{self, HUE_RANGES, PHOTO_FILTERS};
use photocraft_engine::presets::Group;
use photocraft_engine::presets::gradients::GradientPreset;
use serde_json::{Value, json};
use crate::PhotocraftApp;
@ -79,6 +81,8 @@ pub struct EditorCx {
pub gray: bool,
/// Current foreground and background colours (Gradient Map preset).
pub swatches: [[f32; 3]; 2],
/// The session's gradient preset library (Gradient Map's picker; empty for other kinds).
pub gradients: Vec<Group<GradientPreset>>,
/// Hosted by an Image › Adjustments dialog (false: the Properties panel). Photoshop's dialog
/// sliders follow the mouse wheel; the panel's don't.
pub dialog: bool,
@ -1109,22 +1113,34 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
let t = Tokens::get(ui.ctx());
let mut e = Edit::default();
let mut stops = read_stops(v);
let presets: [(&str, Vec<Stop>); 5] = [
(tl!("Black, White"), vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]),
(tl!("Foreground to Background"), vec![(0.0, cx.swatches[0]), (1.0, cx.swatches[1])]),
(tl!("Violet, Orange"), vec![(0.0, [0.161, 0.039, 0.349]), (1.0, [1.0, 0.486, 0.0])]),
(tl!("Blue, Red, Yellow"), vec![(0.0, [0.039, 0.0, 0.698]), (0.5, [1.0, 0.0, 0.0]), (1.0, [1.0, 0.988, 0.0])]),
(tl!("Copper"), vec![(0.0, [0.592, 0.275, 0.102]), (0.4, [0.984, 0.847, 0.773]), (0.7, [0.424, 0.180, 0.086]), (1.0, [0.937, 0.859, 0.804])]),
];
let mut preset = usize::MAX;
// Photoshop's gradient picker: the same library as the Gradient tool and Gradients panel
// (built-in groups and the user's own), Foreground/Background resolved to the current colours.
let [fg, bg] = cx.swatches.map(|c| [c[0], c[1], c[2], 1.0]);
ui.horizontal(|ui| {
label(ui, tl!("Preset:"));
let mut opts: Vec<(usize, &str)> = vec![(usize::MAX, tl!("Custom"))];
opts.extend(presets.iter().enumerate().map(|(i, p)| (i, p.0)));
if widgets::dropdown(ui, &format!("{:?}-gm-preset", cx.mem), &mut preset, &opts, 190.0)
&& let Some(p) = presets.get(preset)
{
stops = p.1.clone();
let mut picked = None;
egui::ComboBox::from_id_salt(cx.mem.with("gm-preset")).selected_text(tl!("Custom")).width(190.0).height(420.0).icon(widgets::chevron_icon).show_ui(
ui,
|ui| {
for g in &cx.gradients {
ui.label(RichText::new(&g.name).color(t.text_faint));
for p in &g.items {
ui.horizontal(|ui| {
// A map has no transparency: colour stops only, shown opaque.
let rgba: Vec<(f32, [f32; 4])> = p.resolve(fg, bg).into_iter().map(|(at, c)| (at, [c[0], c[1], c[2], 1.0])).collect();
let (r, swatch) = ui.allocate_exact_size(vec2(48.0, 16.0), Sense::click());
crate::preset_panels::paint_gradient(ui, r, &rgba);
// Both, not short-circuited: the name is drawn either way.
if swatch.clicked() | ui.selectable_label(false, &p.name).clicked() {
picked = Some(rgba.iter().map(|(at, c)| (*at, [c[0], c[1], c[2]])).collect::<Vec<Stop>>());
}
});
}
}
},
);
if let Some(p) = picked {
stops = p;
write_stops(v, &stops);
e.add(Edit::discrete(true));
}
@ -1202,6 +1218,11 @@ pub fn is_gray(mode: photocraft_doc::ColorMode) -> bool {
matches!(mode, photocraft_doc::ColorMode::Grayscale | photocraft_doc::ColorMode::Duotone | photocraft_doc::ColorMode::Bitmap)
}
/// The gradient preset library for a `kind` editor's picker (Gradient Map only; empty otherwise).
pub fn gradient_presets(app: &PhotocraftApp, kind: &str) -> Vec<Group<GradientPreset>> {
if kind == "gradientMap" { app.session.presets.gradients.clone() } else { Vec::new() }
}
pub fn swatches(app: &PhotocraftApp) -> [[f32; 3]; 2] {
let c = |x: [f32; 4]| [x[0], x[1], x[2]];
[c(app.session.tools.foreground), c(app.session.tools.background)]
@ -1313,7 +1334,7 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj
};
let gray = app.session.active().is_some_and(|s| is_gray(s.doc.mode));
let hist = needs_histogram(kind).then(|| tone::histograms(app, HistSource::BelowLayer(id), space_of(&values)));
let cx = EditorCx { mem: layer_mem(id), hist, gray, swatches: swatches(app), dialog: false };
let cx = EditorCx { mem: layer_mem(id), hist, gray, swatches: swatches(app), gradients: gradient_presets(app, kind), dialog: false };
let e = editor(ui, kind, &mut values, &cx);
if e.changed {
app.live_adjust = Some((id, values.clone()));

View File

@ -396,3 +396,39 @@ fn color_balance_properties_history_and_reset_like_photoshop() {
assert_eq!(cb(&h), ([0.0; 3], [0.0; 3], [0.0; 3], false), "third Reset: still the defaults");
assert_eq!(steps(&h), before, "the edit and the Resets share one history step");
}
fn map_stops(a: &Adjustment) -> Vec<(f32, [u8; 3])> {
let q = |v: f32| (v * 255.0).round() as u8;
match a {
Adjustment::GradientMap { stops, .. } => stops.iter().map(|(t, c)| (*t, [q(c[0]), q(c[1]), q(c[2])])).collect(),
other => panic!("{other:?}"),
}
}
/// Issue #1588: the Gradient Map preset picker offers the session's gradient library (built-in
/// groups beyond Basics and the user's own saved gradients), and picking one writes its stops as
/// one history step.
#[test]
fn gradient_map_picker_offers_the_gradient_library() {
use photocraft_engine::presets::{Group, gradients};
let mut h = app_harness("gradientMap", "rgb", 8);
// A short library so every row fits the popup: Basics, Blues and a user-saved group.
let mut lib: Vec<_> = gradients::builtin().into_iter().take(2).collect();
lib.push(Group::new("Mine", vec![gradients::GradientPreset::new("My Teal", &["#008080", "#ffee00"])]));
h.state_mut().session.presets.gradients = lib;
h.run_steps(2);
let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len();
for (name, want) in
[("Blue 03", vec![(0.0, [0x00, 0xb4, 0xd8]), (1.0, [0x03, 0x04, 0x5e])]), ("My Teal", vec![(0.0, [0x00, 0x80, 0x80]), (1.0, [0xff, 0xee, 0x00])])]
{
let before = steps(&h);
// The picker sits right of its "Preset:" label.
let preset = label_rect(&h, "Preset:").expect("the preset picker");
click(&mut h, Pos2::new(preset.right() + 60.0, preset.center().y));
let item = h.query_by_label(name).unwrap_or_else(|| panic!("`{name}` in the picker")).rect();
click(&mut h, item.center());
h.run_steps(4);
assert_eq!(map_stops(&layer(&h).1), want, "{name}");
assert_eq!(steps(&h), before + 1, "{name}: one history step");
}
}