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

* 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]>

* 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:16:28 -04:00
1 parent 59820ecafb
commit ee9c9ef159
14 files changed
+295 -39

No files matched your search

+80 -12
View File
@@ -761,18 +761,52 @@ pub fn curves_eyedropper(base: &Value, picker: CurvesEyedropper, sample: [f32; 3
/// 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), 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.
/// 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>> {
const CMD: &str = "curves eyedropper";
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)?;
let mut base = p.clone();
if let Some(o) = base.as_object_mut() {
o.remove("eyedropper");
}
curves_eyedropper(&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
/// 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>> {
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 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 (picker, sample) = eyedropper_sample(&d.doc, Some(layer), e)?;
curves_eyedropper(&to_params(&current), picker, sample, d.doc.mode).map(Some)
}
const EYEDROPPER: &str = "curves 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])> {
const CMD: &str = EYEDROPPER;
let picker = match e.get("point").and_then(Value::as_str) {
Some("black") => CurvesEyedropper::Black,
Some("gray") => CurvesEyedropper::NeutralGray,
Some("white") => CurvesEyedropper::White,
_ => return Err(bad(CMD, "`eyedropper.point` must be \"black\", \"gray\" or \"white\"")),
};
let d = s.active().ok_or(EngineError::NoDocument)?;
let triple = |v: &Value| -> Option<[f64; 3]> {
let a = v.as_array().filter(|a| a.len() == 3)?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
@@ -783,12 +817,22 @@ pub fn curves_eyedropper_from_params(s: &crate::Session, p: &Value) -> Result<Op
} else if let Some(at) = e.get("at") {
let xy = at.as_array().filter(|a| a.len() == 2).and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]));
let [x, y] = xy.filter(|xy| xy.iter().all(|v| v.is_finite())).ok_or_else(|| bad(CMD, "`eyedropper.at` must be [x, y] in document pixels"))?;
let (w, h) = (f64::from(d.doc.size.width), f64::from(d.doc.size.height));
let (w, h) = (f64::from(doc.size.width), f64::from(doc.size.height));
if x < 0.0 || y < 0.0 || x >= w || y >= h {
return Err(bad(CMD, "`eyedropper.at` is outside the image"));
}
let rect = photocraft_geom::Rect::from_xywh(x.floor() as i32, y.floor() as i32, 1, 1);
let [r, g, b, a] = photocraft_compose::render(&d.doc, rect).px.first().copied().unwrap_or_default();
let size = crate::sample_cmds::size_param(CMD, e)?;
let all = crate::sample_cmds::SampleLayers::All;
let [r, g, b, a] = match hide {
Some(id) => {
let mut below = doc.clone();
if let Some(l) = below.layer_mut(id) {
l.visible = false;
}
crate::sample_cmds::sample_color(&below, None, x, y, size, all)
}
None => crate::sample_cmds::sample_color(doc, None, x, y, size, all),
};
if a <= 0.0 {
return Err(bad(CMD, "the sampled pixel is transparent"));
}
@@ -796,11 +840,7 @@ pub fn curves_eyedropper_from_params(s: &crate::Session, p: &Value) -> Result<Op
} else {
return Err(bad(CMD, "`eyedropper` needs `at` [x, y] or `color` [r, g, b]"));
};
let mut base = p.clone();
if let Some(o) = base.as_object_mut() {
o.remove("eyedropper");
}
curves_eyedropper(&base, picker, sample, d.doc.mode).map(Some)
Ok((picker, sample))
}
/// `[[in, out], …]` in 0..255: sorted by input, duplicates collapsed, 2..=19 points.
@@ -1314,6 +1354,34 @@ mod tests {
}
}
/// The Properties panel's pickers on a Curves adjustment layer (UI-217-15): one undoable step
/// that samples the image below the layer, so a repeated click keeps the same curve.
#[test]
fn layer_eyedropper_samples_below_the_curves_layer() {
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.newAdjustmentLayer.curves", json!({})).unwrap();
let steps = s.active().unwrap().history.past_len();
let pick = json!({"eyedropper": {"point": "white", "at": [2, 2], "size": 3}});
s.execute("layer.setAdjustment", pick.clone()).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
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), "the sample becomes white: {px:?}");
let curve = s.active().unwrap().doc.clone();
s.execute("layer.setAdjustment", pick).unwrap();
let same = |a: &photocraft_doc::Document, b: &photocraft_doc::Document| a.layers.last().map(|l| &l.content) == b.layers.last().map(|l| &l.content);
assert!(same(&curve, &s.active().unwrap().doc), "sampled below the layer, not through it");
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps);
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.levels", json!({})).unwrap();
assert!(s.execute("layer.setAdjustment", json!({"eyedropper": {"point": "white", "at": [1, 1]}})).is_err(), "Curves only");
}
#[test]
fn curves_eyedropper_support_is_explicit_and_bad_samples_fail() {
assert!(curves_eyedropper(&json!({}), CurvesEyedropper::Black, [0.4; 3], ColorMode::Grayscale).is_ok());
+32 -21
View File
@@ -818,26 +818,37 @@ fn build() -> Vec<CommandSpec> {
Ok(json!({ "layer": id.0 }))
}
),
cmd!("layer.setAdjustment", "Adjustment Properties", [], None, r##"{"layer":id?, …params of that adjustment kind}"##, has_layer, |s, p| {
let id = layer_param(s, p)?;
s.edit("Modify Adjustment", |doc, _| {
let mode = doc.mode;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Adjustment(adj) = &mut l.content else { return Err(EngineError::Other("not an adjustment layer".into())) };
if matches!(adj, Adjustment::Unsupported { .. }) {
return Err(EngineError::Other("this adjustment can't be edited (kept as imported)".into()));
}
let kind = adjustment_kind(adj);
// No params resets to the defaults; otherwise params merge over the current values.
*adj = if crate::adjust_params::user_keys(p).is_empty() {
crate::adjust_params::default_for(kind, mode)?
} else {
crate::adjust_params::from_params(kind, p, Some(adj), mode)?
};
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"layer.setAdjustment",
"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)"##,
has_layer,
|s, p| {
let id = layer_param(s, p)?;
let eyedropped = crate::adjust_params::layer_curves_eyedropper(s, id, p)?;
s.edit("Modify Adjustment", |doc, _| {
let mode = doc.mode;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Adjustment(adj) = &mut l.content else { return Err(EngineError::Other("not an adjustment layer".into())) };
if matches!(adj, Adjustment::Unsupported { .. }) {
return Err(EngineError::Other("this adjustment can't be edited (kept as imported)".into()));
}
let kind = adjustment_kind(adj);
// No params resets to the defaults; otherwise params merge over the current values.
*adj = if let Some(eyedropped) = eyedropped {
eyedropped
} else if crate::adjust_params::user_keys(p).is_empty() {
crate::adjust_params::default_for(kind, mode)?
} else {
crate::adjust_params::from_params(kind, p, Some(adj), mode)?
};
Ok(())
})?;
Ok(Value::Null)
}
),
cmd!(
"layer.moveTo",
"Reorder Layer",
@@ -1034,7 +1045,7 @@ fn build() -> Vec<CommandSpec> {
(
"curves",
"Curves…",
r##"{"points":json,"red":json,"green":json,"blue":json,"pencil":bool=false,"smooth":0..10=0,"eyedropper":json} (curves as [[in,out],…] in 0..255, 2..19 points: points = composite; red/green/blue, gray, cyan/magenta/yellow/black or lightness/a/b per channel; pencil = drawn curves (Draw to modify the curve): each curve is 256 output levels in 0..255 (point curves given are drawn in, and turning pencil off fits points to the drawing); smooth = Smooth passes over the drawn curves; eyedropper = the dialog's Set Black/Neutral Gray/White Point picker {"point":"black|gray|white","at":[x,y]} or {"point",…,"color":[r,g,b] 0..1}, RGB and Grayscale (no gray) only, applied over the given curves)"##,
r##"{"points":json,"red":json,"green":json,"blue":json,"pencil":bool=false,"smooth":0..10=0,"eyedropper":json} (curves as [[in,out],…] in 0..255, 2..19 points: points = composite; red/green/blue, gray, cyan/magenta/yellow/black or lightness/a/b per channel; pencil = drawn curves (Draw to modify the curve): each curve is 256 output levels in 0..255 (point curves given are drawn in, and turning pencil off fits points to the drawing); smooth = Smooth passes over the drawn curves; 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}, RGB and Grayscale (no gray) only, applied over the given curves)"##,
),
("exposure", "Exposure…", r##"{"exposure":-20..20=0,"offset":-0.5..0.5=0,"gamma":0.01..9.99=1}"##),
("vibrance", "Vibrance…", r##"{"vibrance":-100..100=0,"saturation":-100..100=0}"##),
+2 -2
View File
@@ -28,7 +28,7 @@ fn picker(value: Option<&Value>) -> Option<photocraft_engine::adjust_params::Cur
}
}
fn picker_supported(mode: photocraft_doc::ColorMode, eyedropper: photocraft_engine::adjust_params::CurvesEyedropper) -> bool {
pub(crate) fn picker_supported(mode: photocraft_doc::ColorMode, eyedropper: photocraft_engine::adjust_params::CurvesEyedropper) -> bool {
use photocraft_engine::adjust_params::CurvesEyedropper;
mode == photocraft_doc::ColorMode::Rgb || (mode == photocraft_doc::ColorMode::Grayscale && eyedropper != CurvesEyedropper::NeutralGray)
}
@@ -76,7 +76,7 @@ pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool {
true
}
fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, tooltip: &str, color: egui::Color32) -> egui::Response {
pub(crate) fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, tooltip: &str, color: egui::Color32) -> egui::Response {
let response = ui.add_enabled_ui(enabled, |ui| crate::icons::button(ui, "pipette", 28.0, selected, tooltip)).inner;
let tokens = crate::theme::Tokens::get(ui.ctx());
ui.painter().circle_filled(response.rect.right_bottom() - egui::vec2(5.5, 5.5), 3.5, color);
+3
View File
@@ -1485,6 +1485,9 @@ 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" {
crate::layer_picker::controls(app, ui, id);
}
let e = editor(ui, kind, &mut values, &cx);
if e.changed {
app.live_adjust = Some((id, values.clone()));
@@ -476,3 +476,46 @@ fn gradient_map_picker_offers_the_gradient_library() {
assert_eq!(steps(&h), before + 1, "{name}: one history step");
}
}
/// Document point (x, y) on screen in the app's first view.
fn doc_on_screen(h: &Harness<'_, PhotocraftApp>, x: f32, y: f32) -> Pos2 {
let app = h.state();
let v = &app.ui.views[0];
let xf = crate::canvas::ViewXform {
rect: crate::rulers::content_rect(app, app.last_canvas_rect),
zoom: v.zoom,
center: v.center,
flip: app.ui.view.flip_horizontal,
rotation: v.rotation,
aspect: app.ui.view.display_aspect(),
};
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.
#[test]
fn curves_properties_white_point_eyedropper_edits_the_layer() {
let mut h = app_harness("curves", "rgb", 8);
let (_, before) = layer(&h);
let steps = h.state().session.active().unwrap().history.past_len();
h.get_by_label("Set White Point").click();
h.run_steps(2);
assert!(crate::layer_picker::armed(h.state()).is_some(), "armed from the Properties panel");
// The left half is #6a3020: the white point moves to it, so it composites white.
let at = doc_on_screen(&h, 10.0, 10.0);
click(&mut h, at);
let pixel = |h: &mut Harness<'_, PhotocraftApp>| -> Vec<f32> {
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!(crate::layer_picker::armed(h.state()).is_some(), "stays armed for another click");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().ui.layer_picker.is_none(), "Esc disarms");
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(layer(&h).1, before);
}
+21
View File
@@ -2778,6 +2778,8 @@ 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.
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.
let reposition = crate::hold_keys::reposition_held(app, &ctx);
@@ -2888,6 +2890,25 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
view.center[1] -= d.y;
// 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
// 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())
{
if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
} else {
ctx.set_cursor_icon(pipette_cursor(&ctx, p));
}
}
if response.is_pointer_button_down_on()
&& ui.input(|i| i.pointer.primary_pressed())
&& let Some(p) = ui.input(|i| i.pointer.interact_pos())
{
let d = xf.to_doc(p);
crate::layer_picker::sample_at(app, d[0], d[1]);
}
} else if primary && !stroke_dismissal {
let mods = ui.input(|i| i.modifiers);
// An ⌥-click the window manager took never arrives; say so (alt_grab.rs).
+8
View File
@@ -929,6 +929,13 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
}
continue;
}
// A Properties 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);
}
continue;
}
// The dialog owns all pointer events, including Up: no underlying brush stroke
// or context menu may leak through while inspecting a Color Range preview.
if app.ui.dialogs.last().is_some_and(|d| crate::color_range_ui::owns(&d.fields)) {
@@ -1184,6 +1191,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
"typeTransform": app.ui.type_transform,
"camTransform": app.ui.cam_transform,
"layerMenu": app.ui.layer_menu,
"layerPicker": app.ui.layer_picker,
"brushPicker": app.ui.brush_picker.map(|pos| json!({
"pos": pos,
// The picker's content size once its corner grip was dragged (null: the default).
+90
View File
@@ -0,0 +1,90 @@
//! 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.
//!
//! 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
//! panel is not modal, so the picker stays armed until Esc, another tool, another layer, or a
//! second click on its button.
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::PhotocraftApp;
use crate::state::Tool;
use photocraft_doc::{Adjustment, LayerContent, LayerId};
use photocraft_engine::adjust_params::CurvesEyedropper;
/// An armed Properties eyedropper: on `layer`, for `point` ("black", "gray" or "white"), armed
/// while `tool` was the current tool.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LayerPicker {
pub layer: u64,
pub point: String,
pub tool: Tool,
}
const POINTS: [(&str, CurvesEyedropper); 3] = [("black", CurvesEyedropper::Black), ("gray", CurvesEyedropper::NeutralGray), ("white", CurvesEyedropper::White)];
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.
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 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()))
}
/// Drops a picker that no longer applies, so returning to its tool or layer doesn't re-arm it.
pub fn prune(app: &mut PhotocraftApp) {
if app.ui.layer_picker.is_some() && armed(app).is_none() {
app.ui.layer_picker = None;
}
}
/// Esc: disarms the picker. Returns whether there was one.
pub fn cancel(app: &mut PhotocraftApp) -> bool {
app.ui.layer_picker.take().is_some()
}
/// Sets the armed point from the image at document point (x, y): one `layer.setAdjustment`.
/// Returns whether a picker was armed (and so owns the click), even if the sample failed (off
/// the image, transparent): that is reported in the status bar and the picker stays armed.
pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool {
let Some((layer, point)) = armed(app).map(|(l, p)| (l, p.to_string())) else { return false };
let size = app.ui.tool_options.eyedropper_size;
let p = json!({"layer": layer.0, "eyedropper": {"point": point, "at": [x, y], "size": size}});
if let Err(err) = app.run("layer.setAdjustment", p) {
app.ui.status = err;
app.ui.status_error = true;
}
true
}
/// The three eyedropper buttons above the 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());
let mut clicked = None;
ui.horizontal(|ui| {
// The swatch is the tone the picker maps the sample to, as in the dialog.
for ((id, e), (label, tone)) in POINTS.iter().zip([(tl!("Set Black Point"), 0), (tl!("Set Neutral Gray Point"), 128), (tl!("Set White Point"), 255)]) {
let enabled = crate::adjust_dialog::picker_supported(mode, *e);
let selected = current.as_deref() == Some(*id);
if crate::adjust_dialog::picker_button(ui, selected, enabled, label, egui::Color32::from_gray(tone)).clicked() {
clicked = Some((*id, selected));
}
}
});
match clicked {
Some((_, true)) => app.ui.layer_picker = None,
Some((id, false)) => app.ui.layer_picker = Some(LayerPicker { layer: layer.0, point: id.into(), tool: app.ui.tool }),
None => {}
}
}
+1
View File
@@ -97,6 +97,7 @@ pub mod kys_import;
pub mod lasso_ui;
pub mod layer_menu_ui;
pub mod layer_pick_ui;
pub mod layer_picker;
pub mod layer_props_ui;
mod layer_reveal;
pub mod layer_row_ui;
+5
View File
@@ -381,6 +381,11 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
return;
}
}
// A Properties 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;
}
// Crop tool with a pending frame: arrows nudge it (⇧ ×10) and X swaps its orientation, before
// the selection / layer nudge and the X colour swap (#1919).
if focus == Focus::None && crate::crop_ui::keys(app, ctx) {
+5
View File
@@ -1033,6 +1033,10 @@ 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`),
/// exposed to automation but never restored from settings.
#[serde(default, skip_deserializing)]
pub layer_picker: Option<crate::layer_picker::LayerPicker>,
/// Clone Stamp / Healing source point (⌥-click) and the aligned offset once a stroke started.
#[serde(default)]
pub clone_source: Option<[f64; 2]>,
@@ -1226,6 +1230,7 @@ impl Default for UiState {
cam_transform: None,
transform: None,
symmetry_transform: None,
layer_picker: None,
mask_target: false,
vector_mask_target: false,
properties_layer: None,
+1
View File
@@ -67,6 +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.
- `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
+2 -2
View File
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 27 | 14 | 2 | |
| [Workspace and UI](#workspace-and-ui) | 11 | 7 | 5 | settings that do nothing: 30 of 155 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 12 | 6 | 5 | settings that do nothing: 30 of 155 (target 0) |
| [Type](#type) | 4 | 2 | 3 | |
| [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
@@ -196,7 +196,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
| UI-217-12 | Save custom New Document presets; a real Recent category | partial | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard |
| UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click |
| UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) |
| UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). The Image > Adjustments > Curves dialog has the black/gray/white eyedroppers (adjust_dialog.rs, #873); the adjustment layer's Properties panel has none yet |
| UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | done | [#217](https://github.com/storytold/photocraft/issues/217) | Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). Set Black/Neutral Gray/White Point eyedroppers in the Image > Adjustments > Curves dialog (adjust_dialog.rs, #873) and in a Curves adjustment layer's Properties panel (layer_picker.rs: a click on the image is one layer.setAdjustment with the eyedropper param, sampling below the layer; Esc or another tool disarms; #3110) |
| UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue |
| UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout |
| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:1107-1125 no Alt handling |
+2 -2
View File
@@ -106,9 +106,9 @@ note = "panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wh
[[item]]
id = "UI-217-15"
target = "Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips"
status = "partial"
status = "done"
issue = 217
note = "Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). The Image > Adjustments > Curves dialog has the black/gray/white eyedroppers (adjust_dialog.rs, #873); the adjustment layer's Properties panel has none yet"
note = "Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). Set Black/Neutral Gray/White Point eyedroppers in the Image > Adjustments > Curves dialog (adjust_dialog.rs, #873) and in a Curves adjustment layer's Properties panel (layer_picker.rs: a click on the image is one layer.setAdjustment with the eyedropper param, sampling below the layer; Esc or another tool disarms; #3110)"
[[item]]
id = "UI-217-16"