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]> * 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:
11 files changed
+259
-67
No files matched your search
@@ -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(¤t), picker, sample, d.doc.mode).map(Some)
|
||||
adjustment_eyedropper(kind, &to_params(¤t), 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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user