Levels: black, gray and white point eyedroppers (#3116) (#3128)

* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110)

The Set Black/Neutral Gray/White Point pickers existed only in the
Image > Adjustments > Curves dialog (#873). The Properties editor of a
Curves adjustment layer now has them too (layer_picker.rs): with one
armed, a click on the image runs one layer.setAdjustment with the new
`eyedropper` param, which samples the image below the layer (the layer
hidden, like its Properties histogram) at the Eyedropper's Sample Size,
so a repeated click keeps the curve and the edit is one undoable,
recordable step agents can send directly. Esc, another tool or another
layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it.

Scorecard UI-217-15 is done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Levels: black, gray and white point eyedroppers (#3116)

Image > Adjustments > Levels and a Levels adjustment layer's Properties
panel get Photoshop's Set Black/Neutral Gray/White Point eyedroppers.
Black/white move each channel's input black/white to the sample, gray
sets each channel's midtone gamma so the sample turns neutral at its
luma (Grayscale: composite input, no gray). The engine takes the same
`eyedropper` param as Curves on image.adjustments.levels,
layer.newAdjustmentLayer.levels and layer.setAdjustment.

On a smart object, image.adjustments.levels/curves with `eyedropper`
now record the resolved adjustment as the smart filter instead of the
raw sample, which re-rendered as an identity (#3127).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 21:27:08 -04:00
1 parent 6f6d2ea467
commit 9a9d23d258
11 files changed
+259 -67

No files matched your search

+179 -27
View File
@@ -704,19 +704,115 @@ fn inverse_master_target(master: &[CurvePoint], target: f32) -> f32 {
best_input
}
/// The sample and colour modes both eyedroppers (Levels, Curves) support: RGB, and Grayscale
/// without the Neutral Gray picker.
fn check_eyedropper(cmd: &str, picker: CurvesEyedropper, sample: [f32; 3], mode: ColorMode) -> Result<()> {
if sample.iter().any(|v| !v.is_finite()) {
return Err(bad(cmd, "sample components must be finite"));
}
if !matches!(mode, ColorMode::Rgb | ColorMode::Grayscale) {
return Err(bad(cmd, "only RGB and Grayscale documents are supported"));
}
if mode == ColorMode::Grayscale && picker == CurvesEyedropper::NeutralGray {
return Err(bad(cmd, "Neutral Gray is unavailable for Grayscale documents"));
}
Ok(())
}
/// The smallest input range Levels allows (two levels), as the parser enforces.
const LEVELS_MIN_RANGE: f32 = 2.0 / 255.0;
/// Moves the input black (or white) of `c` to `sample`, so the sample maps to the output black
/// (white). The other input end moves away if it would leave less than two levels between them.
fn set_levels_input(c: &mut LevelsChannel, sample: f32, black: bool) {
if black {
c.in_black = sample.clamp(0.0, 1.0 - LEVELS_MIN_RANGE);
c.in_white = c.in_white.max(c.in_black + LEVELS_MIN_RANGE).min(1.0);
} else {
c.in_white = sample.clamp(LEVELS_MIN_RANGE, 1.0);
c.in_black = c.in_black.min(c.in_white - LEVELS_MIN_RANGE).max(0.0);
}
}
/// Sets the midtone gamma of `c` so `sample` maps to `target` (both 0..1). Bisection over the
/// gamma range on the compositor's own Levels curve (monotone in gamma), so the shadow toe is
/// accounted for. A sample at or outside the input range, or a target outside the output range,
/// can't be reached by gamma and leaves `c` unchanged.
fn set_levels_gamma(c: &mut LevelsChannel, sample: f32, target: f32) {
let u = (sample - c.in_black) / (c.in_white - c.in_black).max(1e-6);
let span = c.out_white - c.out_black;
let t = (target - c.out_black) / span;
if !(u > 0.0 && u < 1.0) || span.abs() < 1e-6 || !(t > 0.0 && t < 1.0) {
return;
}
let reached = |gamma: f32| {
let trial = LevelsChannel { gamma, ..c.clone() };
(photocraft_compose::adjust::levels(&trial, sample) - c.out_black) / span
};
let (mut lo, mut hi) = (0.01f32.ln(), 9.99f32.ln());
for _ in 0..40 {
let mid = (lo + hi) / 2.0;
if reached(mid.exp()) < t {
lo = mid;
} else {
hi = mid;
}
}
c.gamma = ((lo + hi) / 2.0).exp().clamp(0.01, 9.99);
}
/// Photoshop's Levels eyedroppers on the Levels in `base` (params, as for `from_params`), from a
/// colour sample of the image the Levels adjust. RGB: Set Black/White Point move each channel's
/// input black/white to the sample's value in that channel, so the sample maps to the output
/// black/white; Set Neutral Gray Point sets each channel's midtone gamma so the channel maps to
/// the sample's luma, removing the cast while keeping its brightness. The composite record is
/// kept. Grayscale: Black/White move the composite input; there is no gray picker.
pub fn levels_eyedropper(base: &Value, picker: CurvesEyedropper, sample: [f32; 3], mode: ColorMode) -> Result<Adjustment> {
const CMD: &str = "levels eyedropper";
check_eyedropper(CMD, picker, sample, mode)?;
let mut adjustment = from_params("levels", base, None, mode)?;
let Adjustment::Levels { master, per_channel, space, .. } = &mut adjustment else {
return Err(bad(CMD, "expected a Levels adjustment"));
};
if *space != ToneSpace::Rgb {
return Err(bad(CMD, "only RGB channel Levels are supported"));
}
let sample = sample.map(|v| v.clamp(0.0, 1.0));
if mode == ColorMode::Grayscale {
set_levels_input(master, (sample[0] + sample[1] + sample[2]) / 3.0, picker == CurvesEyedropper::Black);
return Ok(adjustment);
}
match picker {
CurvesEyedropper::Black | CurvesEyedropper::White => {
for (c, v) in per_channel.iter_mut().zip(sample) {
set_levels_input(c, v, picker == CurvesEyedropper::Black);
}
}
CurvesEyedropper::NeutralGray => {
let target = rgb_luma(sample);
for (c, v) in per_channel.iter_mut().zip(sample) {
set_levels_gamma(c, v, target);
}
}
}
Ok(adjustment)
}
/// The eyedropper of adjustment `kind` ("levels" or "curves"): [`levels_eyedropper`] or
/// [`curves_eyedropper`].
pub fn adjustment_eyedropper(kind: &str, base: &Value, picker: CurvesEyedropper, sample: [f32; 3], mode: ColorMode) -> Result<Adjustment> {
match kind {
"levels" => levels_eyedropper(base, picker, sample, mode),
"curves" => curves_eyedropper(base, picker, sample, mode),
_ => Err(bad(EYEDROPPER, "`eyedropper` applies to Levels and Curves only")),
}
}
/// Resolve a merged-composite colour sample into the black, luma-preserving neutral-gray or white target.
/// RGB uses the three channel curves. Grayscale uses the composite curve and has no gray picker.
/// Other colour models stay unsupported until their native channel semantics can be sampled.
pub fn curves_eyedropper(base: &Value, picker: CurvesEyedropper, sample: [f32; 3], mode: ColorMode) -> Result<Adjustment> {
if sample.iter().any(|v| !v.is_finite()) {
return Err(bad("curves eyedropper", "sample components must be finite"));
}
if !matches!(mode, ColorMode::Rgb | ColorMode::Grayscale) {
return Err(bad("curves eyedropper", "only RGB and Grayscale documents are supported"));
}
if mode == ColorMode::Grayscale && picker == CurvesEyedropper::NeutralGray {
return Err(bad("curves eyedropper", "Neutral Gray is unavailable for Grayscale documents"));
}
check_eyedropper("curves eyedropper", picker, sample, mode)?;
// The pickers place points: a pencil-drawn curve is first fitted with points, as when
// Photoshop leaves the pencil for point editing.
let mut base = base.clone();
@@ -759,12 +855,13 @@ pub fn curves_eyedropper(base: &Value, picker: CurvesEyedropper, sample: [f32; 3
Ok(adjustment)
}
/// The `eyedropper` param of the Curves commands, so agents and the CLI can drive the dialog's
/// Set Black/Neutral Gray/White Point pickers: `{"point":"black"|"gray"|"white","at":[x,y]}`
/// samples the merged composite at document pixel (x, y) (`"size"`: the Eyedropper's Sample
/// Size, 1 = Point Sample), or `"color":[r,g,b]` (0–1) gives the sample directly. The rest of `p`
/// is the curve the picker edits. `None` without the key.
pub fn curves_eyedropper_from_params(s: &crate::Session, p: &Value) -> Result<Option<Adjustment>> {
/// The `eyedropper` param of the Levels and Curves commands (`kind`), so agents and the CLI can
/// drive the dialog's Set Black/Neutral Gray/White Point pickers:
/// `{"point":"black"|"gray"|"white","at":[x,y]}` samples the merged composite at document pixel
/// (x, y) (`"size"`: the Eyedropper's Sample Size, 1 = Point Sample), or `"color":[r,g,b]` (0–1)
/// gives the sample directly. The rest of `p` is the adjustment the picker edits. `None` without
/// the key.
pub fn eyedropper_from_params(s: &crate::Session, kind: &str, p: &Value) -> Result<Option<Adjustment>> {
let Some(e) = p.get("eyedropper") else { return Ok(None) };
let d = s.active().ok_or(EngineError::NoDocument)?;
let (picker, sample) = eyedropper_sample(&d.doc, None, e)?;
@@ -772,31 +869,33 @@ pub fn curves_eyedropper_from_params(s: &crate::Session, p: &Value) -> Result<Op
if let Some(o) = base.as_object_mut() {
o.remove("eyedropper");
}
curves_eyedropper(&base, picker, sample, d.doc.mode).map(Some)
adjustment_eyedropper(kind, &base, picker, sample, d.doc.mode).map(Some)
}
/// The `eyedropper` param of `layer.setAdjustment` on Curves adjustment layer `layer`: the
/// Properties panel's Set Black/Neutral Gray/White Point pickers. `at` samples the image the
/// The `eyedropper` param of `layer.setAdjustment` on Levels or Curves adjustment layer `layer`:
/// the Properties panel's Set Black/Neutral Gray/White Point pickers. `at` samples the image the
/// layer adjusts (the composite with the layer hidden, like its Properties histogram), so clicking
/// the same pixel again maps it the same way. The picker edits the layer's curve with the other
/// keys of `p` merged over it. `None` without the key.
pub fn layer_curves_eyedropper(s: &crate::Session, layer: photocraft_doc::LayerId, p: &Value) -> Result<Option<Adjustment>> {
/// the same pixel again maps it the same way. The picker edits the layer's adjustment with the
/// other keys of `p` merged over it. `None` without the key.
pub fn layer_eyedropper(s: &crate::Session, layer: photocraft_doc::LayerId, p: &Value) -> Result<Option<Adjustment>> {
let Some(e) = p.get("eyedropper") else { return Ok(None) };
let d = s.active().ok_or(EngineError::NoDocument)?;
let l = d.doc.layer(layer).ok_or(EngineError::NoLayer(layer))?;
let photocraft_doc::LayerContent::Adjustment(adj @ Adjustment::Curves { .. }) = &l.content else {
return Err(bad(EYEDROPPER, "`eyedropper` applies to Curves adjustment layers only"));
let (kind, adj) = match &l.content {
photocraft_doc::LayerContent::Adjustment(adj @ Adjustment::Curves { .. }) => ("curves", adj),
photocraft_doc::LayerContent::Adjustment(adj @ Adjustment::Levels { .. }) => ("levels", adj),
_ => return Err(bad(EYEDROPPER, "`eyedropper` applies to Levels and Curves adjustment layers only")),
};
let mut rest = p.clone();
if let Some(o) = rest.as_object_mut() {
o.remove("eyedropper");
}
let current = if user_keys(&rest).is_empty() { adj.clone() } else { from_params("curves", &rest, Some(adj), d.doc.mode)? };
let current = if user_keys(&rest).is_empty() { adj.clone() } else { from_params(kind, &rest, Some(adj), d.doc.mode)? };
let (picker, sample) = eyedropper_sample(&d.doc, Some(layer), e)?;
curves_eyedropper(&to_params(&current), picker, sample, d.doc.mode).map(Some)
adjustment_eyedropper(kind, &to_params(&current), picker, sample, d.doc.mode).map(Some)
}
const EYEDROPPER: &str = "curves eyedropper";
const EYEDROPPER: &str = "eyedropper";
/// The picker and sample of an `eyedropper` param; `at` samples `doc` with layer `hide` hidden.
fn eyedropper_sample(doc: &photocraft_doc::Document, hide: Option<photocraft_doc::LayerId>, e: &Value) -> Result<(CurvesEyedropper, [f32; 3])> {
@@ -1378,8 +1477,61 @@ mod tests {
for bad in [json!({"point": "black", "at": [99, 1]}), json!({"point": "black", "at": [1, 1], "size": 2}), json!({"point": "x", "at": [1, 1]})] {
assert!(s.execute("layer.setAdjustment", json!({"eyedropper": bad})).is_err(), "{bad}");
}
s.execute("layer.newAdjustmentLayer.hueSaturation", json!({})).unwrap();
assert!(s.execute("layer.setAdjustment", json!({"eyedropper": {"point": "white", "at": [1, 1]}})).is_err(), "Levels and Curves only");
}
/// #3127: on a smart object the eyedropper param records the adjustment it resolved to as
/// the smart filter, not the raw sample (which re-rendered as an identity).
#[test]
fn eyedropper_on_a_smart_object_records_the_resolved_adjustment() {
for kind in ["curves", "levels"] {
let mut s = crate::Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("edit.fill", json!({"color": [0.6, 0.5, 0.4]})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute(&format!("image.adjustments.{kind}"), json!({"eyedropper": {"point": "white", "at": [2, 2]}})).unwrap();
let px: Vec<f32> = serde_json::from_value(s.execute("document.pixel", json!({"x": 2, "y": 2})).unwrap()).unwrap();
assert!(px.iter().take(3).all(|v| *v > 0.99), "{kind}: {px:?}");
}
}
/// Levels' Set White/Neutral Gray Point (#3116): the white point maps the sampled colour to
/// white at 8 and 16 bit, the gray point neutralises a tinted grey at its luma on a Levels
/// layer as one undoable step, and bad picks fail.
#[test]
fn levels_eyedroppers_set_white_and_neutral_gray() {
let pixel = |s: &mut crate::Session| -> Vec<f32> { serde_json::from_value(s.execute("document.pixel", json!({"x": 2, "y": 2})).unwrap()).unwrap() };
for depth in [8, 16] {
let mut s = crate::Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
s.execute("edit.fill", json!({"color": [0.6, 0.5, 0.4]})).unwrap();
s.execute("image.adjustments.levels", json!({"eyedropper": {"point": "white", "at": [2, 2]}})).unwrap();
let px = pixel(&mut s);
assert!(px.iter().take(3).all(|v| *v > 0.995), "{depth}-bit: the sample becomes white: {px:?}");
}
let mut s = crate::Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("edit.fill", json!({"color": [0.6, 0.5, 0.4]})).unwrap();
let before = pixel(&mut s);
s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap();
assert!(s.execute("layer.setAdjustment", json!({"eyedropper": {"point": "white", "at": [1, 1]}})).is_err(), "Curves only");
let steps = s.active().unwrap().history.past_len();
s.execute("layer.setAdjustment", json!({"eyedropper": {"point": "gray", "at": [2, 2]}})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
let px = pixel(&mut s);
let (lo, hi) = px.iter().take(3).fold((1.0f32, 0.0f32), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
assert!(hi - lo < 2.5 / 255.0, "neutral: {px:?}");
let luma = |c: &[f32]| c[0] * 0.2126 + c[1] * 0.7152 + c[2] * 0.0722;
assert!((luma(&px) - luma(&before)).abs() < 2.0 / 255.0, "keeps the luma: {px:?} vs {before:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(pixel(&mut s), before);
for bad in [json!({"point": "gray", "at": [99, 1]}), json!({"point": "x", "at": [1, 1]}), json!({"point": "white", "color": [f64::NAN, 0, 0]})] {
assert!(s.execute("layer.setAdjustment", json!({"eyedropper": bad})).is_err(), "{bad}");
assert!(s.execute("image.adjustments.levels", json!({"eyedropper": bad})).is_err(), "{bad}");
}
s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap();
assert!(s.execute("image.adjustments.levels", json!({"eyedropper": {"point": "gray", "at": [1, 1]}})).is_err(), "no gray picker in Grayscale");
s.execute("image.adjustments.levels", json!({"eyedropper": {"point": "black", "color": [1.0, 1.0, 1.0]}})).unwrap();
}
#[test]
+16 -6
View File
@@ -823,11 +823,11 @@ fn build() -> Vec<CommandSpec> {
"Adjustment Properties",
[],
None,
r##"{"layer":id?, …params of that adjustment kind} (Curves: "eyedropper":{"point":"black|gray|white","at":[x,y],"size":1..101=1} or {"point",…,"color":[r,g,b] 0..1} = the Properties panel's Set Black/Neutral Gray/White Point, sampling the image below the layer, RGB and Grayscale (no gray) only)"##,
r##"{"layer":id?, …params of that adjustment kind} (Levels, Curves: "eyedropper":{"point":"black|gray|white","at":[x,y],"size":1..101=1} or {"point",…,"color":[r,g,b] 0..1} = the Properties panel's Set Black/Neutral Gray/White Point, sampling the image below the layer, RGB and Grayscale (no gray) only)"##,
has_layer,
|s, p| {
let id = layer_param(s, p)?;
let eyedropped = crate::adjust_params::layer_curves_eyedropper(s, id, p)?;
let eyedropped = crate::adjust_params::layer_eyedropper(s, id, p)?;
s.edit("Modify Adjustment", |doc, _| {
let mode = doc.mode;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
@@ -1040,7 +1040,7 @@ fn build() -> Vec<CommandSpec> {
(
"levels",
"Levels…",
r##"{"inBlack":0..253=0,"gamma":0.01..9.99=1,"inWhite":2..255=255,"outBlack":0..255=0,"outWhite":0..255=255,"red":json,"green":json,"blue":json} (top level = composite; per channel {"inBlack","gamma","inWhite","outBlack","outWhite"} under red/green/blue, gray, cyan/magenta/yellow/black or lightness/a/b)"##,
r##"{"inBlack":0..253=0,"gamma":0.01..9.99=1,"inWhite":2..255=255,"outBlack":0..255=0,"outWhite":0..255=255,"red":json,"green":json,"blue":json,"eyedropper":json} (top level = composite; per channel {"inBlack","gamma","inWhite","outBlack","outWhite"} under red/green/blue, gray, cyan/magenta/yellow/black or lightness/a/b; eyedropper = the dialog's Set Black/Neutral Gray/White Point picker {"point":"black|gray|white","at":[x,y],"size":1..101=1} or {"point",…,"color":[r,g,b] 0..1}: black/white set each channel's input black/white to the sample, gray each channel's gamma so the sample turns neutral at its luma; RGB and Grayscale (no gray) only, applied over the given levels)"##,
),
(
"curves",
@@ -1101,7 +1101,7 @@ fn build() -> Vec<CommandSpec> {
enabled: has_doc,
run: |s, p| {
let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string();
let eyedropped = if kind == "curves" { crate::adjust_params::curves_eyedropper_from_params(s, p)? } else { None };
let eyedropped = if matches!(kind.as_str(), "levels" | "curves") { crate::adjust_params::eyedropper_from_params(s, &kind, p)? } else { None };
let adj = match eyedropped {
Some(adj) => adj,
None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?,
@@ -1127,7 +1127,17 @@ fn build() -> Vec<CommandSpec> {
enabled: crate::adjust_cmds::has_adjustable,
run: |s, p| {
let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string();
let eyedropped = if kind == "curves" { crate::adjust_params::curves_eyedropper_from_params(s, p)? } else { None };
let eyedropped = if matches!(kind.as_str(), "levels" | "curves") { crate::adjust_params::eyedropper_from_params(s, &kind, p)? } else { None };
// A smart filter re-runs its params, so it records what an eyedropper resolved
// to, not the sample (#3127).
let recorded = eyedropped.as_ref().map(|adj| {
let mut q = p.clone();
if let (Value::Object(q), Value::Object(resolved)) = (&mut q, crate::adjust_params::to_params(adj)) {
q.remove("eyedropper");
q.extend(resolved);
}
q
});
let adj = match eyedropped {
Some(adj) => adj,
None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?,
@@ -1135,7 +1145,7 @@ fn build() -> Vec<CommandSpec> {
let label = adj.label().to_string();
// A smart object records the adjustment as a smart filter (Photoshop), which
// re-runs from the source instead of baking into a copy of the pixels.
if crate::adjust_cmds::adjust_as_smart_filter(s, &kind, &label, p)? {
if crate::adjust_cmds::adjust_as_smart_filter(s, &kind, &label, recorded.as_ref().unwrap_or(p))? {
return Ok(Value::Null);
}
destructive_adjust(s, &label, adj, p)
+31 -13
View File
@@ -33,25 +33,28 @@ pub(crate) fn picker_supported(mode: photocraft_doc::ColorMode, eyedropper: phot
mode == photocraft_doc::ColorMode::Rgb || (mode == photocraft_doc::ColorMode::Grayscale && eyedropper != CurvesEyedropper::NeutralGray)
}
fn is_curves(fields: &Map<String, Value>) -> bool {
fields.get("__adjust").and_then(Value::as_str) == Some("curves")
/// The kind of an adjustment dialog with Set Black/Neutral Gray/White Point eyedroppers (Levels,
/// Curves), if `fields` are one.
fn picker_kind(fields: &Map<String, Value>) -> Option<&str> {
fields.get("__adjust").and_then(Value::as_str).filter(|k| matches!(*k, "levels" | "curves"))
}
/// Whether the top modal has a supported Curves eyedropper armed.
/// Whether the top modal has a supported Levels or Curves eyedropper armed.
pub fn picker_armed(app: &PhotocraftApp) -> bool {
let Some(dialog) = app.ui.dialogs.last().filter(|d| is_curves(&d.fields)) else { return false };
let Some(dialog) = app.ui.dialogs.last().filter(|d| picker_kind(&d.fields).is_some()) else { return false };
let Some(eyedropper) = picker(dialog.fields.get(CURVE_PICKER)) else { return false };
app.session.active().is_some_and(|s| picker_supported(s.doc.mode, eyedropper))
}
/// Sample the committed merged document through the regular canvas sampler and update only the
/// open Curves dialog. Returns true when an armed Curves picker consumed the pointer event, even
/// if the point was outside the image or transparent.
/// open Levels or Curves dialog. Returns true when an armed picker consumed the pointer event,
/// even if the point was outside the image or transparent.
pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool {
let Some((id, eyedropper, base)) = app.ui.dialogs.last().and_then(|dialog| {
let eyedropper = is_curves(&dialog.fields).then(|| picker(dialog.fields.get(CURVE_PICKER))).flatten()?;
let Some((id, kind, eyedropper, base)) = app.ui.dialogs.last().and_then(|dialog| {
let kind = picker_kind(&dialog.fields)?.to_string();
let eyedropper = picker(dialog.fields.get(CURVE_PICKER))?;
let base = dialog.fields.get(CURVE_PICKER_BASE).cloned().unwrap_or_else(|| crate::filter_dialog::params_of(&dialog.fields));
Some((dialog.id, eyedropper, base))
Some((dialog.id, kind, eyedropper, base))
}) else {
return false;
};
@@ -60,7 +63,7 @@ pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool {
return true;
}
let Some(sample) = crate::canvas::composite_color(app, x, y) else { return true };
let params = match photocraft_engine::adjust_params::curves_eyedropper(&base, eyedropper, sample, mode) {
let params = match photocraft_engine::adjust_params::adjustment_eyedropper(&kind, &base, eyedropper, sample, mode) {
Ok(adjustment) => photocraft_engine::adjust_params::to_params(&adjustment),
Err(error) => {
app.ui.status = error.to_string();
@@ -85,7 +88,7 @@ pub(crate) fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, to
response
}
fn curves_picker_controls(ui: &mut egui::Ui, fields: &mut Map<String, Value>, mode: photocraft_doc::ColorMode) {
fn picker_controls(ui: &mut egui::Ui, fields: &mut Map<String, Value>, mode: photocraft_doc::ColorMode) {
use photocraft_engine::adjust_params::CurvesEyedropper;
let current = picker(fields.get(CURVE_PICKER));
let mut chosen = None;
@@ -171,8 +174,8 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string();
let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode);
if kind == "curves" {
curves_picker_controls(ui, fields, mode);
if matches!(kind.as_str(), "levels" | "curves") {
picker_controls(ui, fields, mode);
ui.add_space(4.0);
}
let mut values = crate::filter_dialog::params_of(fields);
@@ -526,6 +529,21 @@ mod tests {
assert_eq!(st.history.past_len(), steps);
}
/// #3116: the Levels dialog's Set White Point moves each channel's input white to the sample
/// (#b04020) and leaves the document alone until OK.
#[test]
fn levels_white_point_eyedropper_sets_the_channel_input_whites() {
let mut app = app_with_image();
let original = app.session.active().unwrap().doc.clone();
let id = open(&mut app, "image.adjustments.levels").unwrap();
arm(&mut app, id, "white");
assert!(picker_armed(&app));
assert!(sample_at(&mut app, 8.0, 8.0));
let in_white = |key: &str| app.ui.dialogs.last().and_then(|d| d.fields.get(key)).and_then(|c| c.get("inWhite")).and_then(Value::as_f64);
assert_eq!([in_white("red"), in_white("green"), in_white("blue")], [Some(176.0), Some(64.0), Some(32.0)]);
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original));
}
#[test]
fn curves_picker_controls_arm_and_disable_by_document_mode() {
let harness = |app| {
+1 -1
View File
@@ -1485,7 +1485,7 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj
dialog: false,
dynamic_color_sliders: app.session.prefs().interface.dynamic_color_sliders,
};
if kind == "curves" {
if matches!(kind, "levels" | "curves") {
crate::layer_picker::controls(app, ui, id);
}
let e = editor(ui, kind, &mut values, &cx);
+17 -6
View File
@@ -492,12 +492,22 @@ fn doc_on_screen(h: &Harness<'_, PhotocraftApp>, x: f32, y: f32) -> Pos2 {
xf.to_screen(x, y)
}
/// UI-217-15 (#3110): the Curves layer's Properties panel has the Set Black/Neutral Gray/White
/// Point eyedroppers. With Set White Point armed, a click on the image makes that pixel white as
/// one history step (sampled below the layer), and Esc disarms the picker.
/// UI-217-15 (#3110, #3116): the Properties panel of a Curves or Levels layer has the Set
/// Black/Neutral Gray/White Point eyedroppers. With Set White Point armed, a click on the image
/// makes that pixel white as one history step (sampled below the layer), and Esc disarms the
/// picker.
#[test]
fn curves_properties_white_point_eyedropper_edits_the_layer() {
let mut h = app_harness("curves", "rgb", 8);
properties_white_point_eyedropper_edits_the_layer("curves");
}
#[test]
fn levels_properties_white_point_eyedropper_edits_the_layer() {
properties_white_point_eyedropper_edits_the_layer("levels");
}
fn properties_white_point_eyedropper_edits_the_layer(kind: &str) {
let mut h = app_harness(kind, "rgb", 8);
let (_, before) = layer(&h);
let steps = h.state().session.active().unwrap().history.past_len();
h.get_by_label("Set White Point").click();
@@ -510,8 +520,9 @@ fn curves_properties_white_point_eyedropper_edits_the_layer() {
serde_json::from_value(h.state_mut().run("document.pixel", json!({"x": 10, "y": 10})).unwrap()).unwrap()
};
let px = pixel(&mut h);
assert!(px.iter().take(3).all(|v| *v > 0.99), "{px:?}");
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one history step");
assert!(px.iter().take(3).all(|v| *v > 0.99), "{kind}: {px:?}");
assert_ne!(layer(&h).1, before, "{kind}: the layer changed");
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "{kind}: one history step");
assert!(crate::layer_picker::armed(h.state()).is_some(), "stays armed for another click");
h.key_press(egui::Key::Escape);
h.run_steps(2);
+2 -2
View File
@@ -2778,7 +2778,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crate::stylus::Stylus::sync_eraser_tool(app);
// A Magnetic Lasso border left behind by a tool or document switch is dropped.
crate::magnetic_lasso_ui::frame(app);
// A Properties Curves eyedropper left behind by a tool or layer switch is dropped.
// A Properties Levels/Curves eyedropper left behind by a tool or layer switch is dropped.
crate::layer_picker::prune(app);
// Held keys (hold_keys.rs): Space repositions a crop frame, marquee, lasso or shape being
// drawn; otherwise Space is the Hand and ⌘Space / ⌥Space the Zoom tool while held.
@@ -2891,7 +2891,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// The tool cursors below are skipped while panning: the fist is set here (#2196).
ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, true));
} else if primary && !stroke_dismissal && temporary.is_none() && !middle && crate::layer_picker::armed(app).is_some() {
// A Properties Curves eyedropper (layer_picker.rs) owns the image until Esc or another
// A Properties Levels/Curves eyedropper (layer_picker.rs) owns the image until Esc or another
// tool; Space and the middle button still pan. It samples on the press.
if response.hovered()
&& let Some(p) = ui.input(|i| i.pointer.hover_pos())
+1 -1
View File
@@ -929,7 +929,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
}
continue;
}
// A Properties Curves eyedropper owns the primary button the same way.
// A Properties Levels/Curves eyedropper owns the primary button the same way.
if app.ui.dialogs.is_empty() && !matches!(s("button"), Some("secondary" | "right")) && crate::layer_picker::armed(app).is_some() {
if matches!(ev, ToolEvent::Down { .. }) {
crate::layer_picker::sample_at(app, x, y);
+9 -8
View File
@@ -1,9 +1,10 @@
//! The Set Black / Neutral Gray / White Point eyedroppers of a Curves adjustment layer's
//! Properties panel (UI-217-15), as Photoshop has them next to the curve.
//! The Set Black / Neutral Gray / White Point eyedroppers of a Levels or Curves adjustment
//! layer's Properties panel (UI-217-15, #3116), as Photoshop has them next to the histogram or
//! curve.
//!
//! Arming one turns a click on the image into one `layer.setAdjustment` with the `eyedropper`
//! param (one undoable, recordable step, the same thing an agent sends), which samples the image
//! the layer adjusts. Unlike the modal Image › Adjustments › Curves dialog (`adjust_dialog`), the
//! the layer adjusts. Unlike the modal Image › Adjustments › Levels and Curves dialogs (`adjust_dialog`), the
//! panel is not modal, so the picker stays armed until Esc, another tool, another layer, or a
//! second click on its button.
@@ -30,15 +31,15 @@ fn eyedropper(point: &str) -> Option<CurvesEyedropper> {
POINTS.iter().find(|(id, _)| *id == point).map(|(_, e)| *e)
}
/// The armed picker if it still applies: same tool, the layer still the active Curves layer, and
/// the point supported in the document's colour mode.
/// The armed picker if it still applies: same tool, the layer still the active Levels or Curves
/// layer, and the point supported in the document's colour mode.
pub fn armed(app: &PhotocraftApp) -> Option<(LayerId, &str)> {
let picker = app.ui.layer_picker.as_ref()?;
let st = app.session.active()?;
let layer = LayerId(picker.layer);
let curves = st.doc.layer(layer).is_some_and(|l| matches!(l.content, LayerContent::Adjustment(Adjustment::Curves { .. })));
let tonal = st.doc.layer(layer).is_some_and(|l| matches!(l.content, LayerContent::Adjustment(Adjustment::Levels { .. } | Adjustment::Curves { .. })));
let supported = eyedropper(&picker.point).is_some_and(|e| crate::adjust_dialog::picker_supported(st.doc.mode, e));
(picker.tool == app.ui.tool && st.active_layer == Some(layer) && curves && supported).then_some((layer, picker.point.as_str()))
(picker.tool == app.ui.tool && st.active_layer == Some(layer) && tonal && supported).then_some((layer, picker.point.as_str()))
}
/// Drops a picker that no longer applies, so returning to its tool or layer doesn't re-arm it.
@@ -67,7 +68,7 @@ pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool {
true
}
/// The three eyedropper buttons above the Curves editor of adjustment layer `layer`.
/// The three eyedropper buttons above the Levels or Curves editor of adjustment layer `layer`.
pub fn controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: LayerId) {
let Some(mode) = app.session.active().map(|s| s.doc.mode) else { return };
let current = armed(app).filter(|(l, _)| *l == layer).map(|(_, p)| p.to_string());
+1 -1
View File
@@ -381,7 +381,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
return;
}
}
// A Properties Curves eyedropper: Esc disarms it.
// A Properties Levels/Curves eyedropper: Esc disarms it.
if crate::layer_picker::armed(app).is_some() && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
crate::layer_picker::cancel(app);
return;
+1 -1
View File
@@ -1033,7 +1033,7 @@ pub struct UiState {
/// Temporary symmetry-axis editing, exposed to automation but never restored from settings.
#[serde(default, skip_deserializing)]
pub symmetry_transform: Option<crate::symmetry_ui::Transform>,
/// The Properties panel's armed Curves eyedropper on an adjustment layer (`layer_picker`),
/// The Properties panel's armed Levels/Curves eyedropper on an adjustment layer (`layer_picker`),
/// exposed to automation but never restored from settings.
#[serde(default, skip_deserializing)]
pub layer_picker: Option<crate::layer_picker::LayerPicker>,
+1 -1
View File
@@ -67,7 +67,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point.
- Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape.
- Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it.
- Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it.
- `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
- `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default