mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* 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:
14 files changed
+295
-39
No files matched your search
@@ -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(¤t), 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());
|
||||
|
||||
@@ -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}"##),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user