diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index e52343b69e..2b6225f992 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -188,7 +188,9 @@ pub struct UiSetParams { /// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, /// studioLight, classic), brushSection, brushTab, brushesView, brushPicker ([x, y] opens the /// Brush Preset picker there, null closes it), brushPickerView, brushSize, gradientBlendMode - /// (a blend mode name, for the Gradient tool), gradientClassic (bool). Other fields are an + /// (a blend mode name, for the Gradient tool), gradientClassic (bool), eyedropperSampleSize + /// ("point" or 1, 3, 5, 11, 31, 51, 101), eyedropperSample (current, currentAndBelow, all, + /// allNoAdjustments, currentAndBelowNoAdjustments), eyedropperRing (bool). Other fields are an /// error. pub fields: Value, } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index acd12eb00d..37f5f1fb76 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1125,6 +1125,7 @@ fn build() -> Vec { v.extend(crate::prefs::specs()); v.extend(crate::edit_menu_cmds::specs()); v.extend(crate::fill_key_cmds::specs()); + v.extend(crate::sample_cmds::specs()); v.extend(crate::brush_key_cmds::specs()); v.extend(crate::stamp_cmds::specs()); v.extend(crate::align_cmds::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b1a411a2c4..5e4b8352e9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -75,6 +75,7 @@ pub mod proof_sim; pub mod redeye_cmds; pub mod render_cmds; pub mod retouch_cmds; +pub mod sample_cmds; pub mod select_extra_cmds; pub mod selection_cmds; pub mod slice_cmds; diff --git a/crates/engine/src/sample_cmds.rs b/crates/engine/src/sample_cmds.rs new file mode 100644 index 0000000000..b44efb7ac3 --- /dev/null +++ b/crates/engine/src/sample_cmds.rs @@ -0,0 +1,462 @@ +//! Eyedropper sampling (#1649): Photoshop's Sample Size (Point Sample, or the average of a +//! 3×3 … 101×101 square centred on the clicked pixel) and Sample (which layers the colour comes +//! from). The Eyedropper tool, the painting tools' ⌥-click eyedropper and the Info panel read +//! colours through [`sample_color`], so they share one setting, as in Photoshop. +//! +//! Averages are taken in the document's own colour model and bit depth: the composite under the +//! square is converted to the document's channels (Gray, Lab; CMYK documents are composited in +//! RGB, so their composite RGB is averaged, see [`averaging_model`]), averaged weighted by alpha, +//! rounded to the document's depth (8 and 16 bit; 32 bit keeps the float), then returned as the +//! straight-alpha RGBA the compositor and the tool colours use. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, LayerContent, LayerId}; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// The Sample Size choices: 1 = Point Sample, otherwise the side of the averaged square. +pub const SAMPLE_SIZES: [u32; 7] = [1, 3, 5, 11, 31, 51, 101]; + +/// Which layers the Eyedropper samples (its options bar's Sample menu). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SampleLayers { + /// The active layer alone. + Current, + /// The active layer and everything below it. + CurrentAndBelow, + /// The whole visible composite (Photoshop's default). + #[default] + All, + /// [`Self::CurrentAndBelow`] with adjustment layers left out. + CurrentAndBelowNoAdjustments, + /// [`Self::All`] with adjustment layers left out. + AllNoAdjustments, +} + +impl SampleLayers { + /// Every choice with its param id, in the options bar's order. + pub const ALL: [(SampleLayers, &'static str); 5] = [ + (SampleLayers::Current, "current"), + (SampleLayers::CurrentAndBelow, "currentAndBelow"), + (SampleLayers::All, "all"), + (SampleLayers::AllNoAdjustments, "allNoAdjustments"), + (SampleLayers::CurrentAndBelowNoAdjustments, "currentAndBelowNoAdjustments"), + ]; + + /// The choice named `id` (the `sampleLayer` param), `None` for an unknown one. + pub fn parse(id: &str) -> Option { + Self::ALL.iter().find(|(_, n)| *n == id).map(|(v, _)| *v) + } + + fn no_adjustments(self) -> bool { + matches!(self, SampleLayers::AllNoAdjustments | SampleLayers::CurrentAndBelowNoAdjustments) + } +} + +/// The pixels a sample of `size` at document point (`x`, `y`) averages: the `size`×`size` square +/// centred on the pixel under the point, clipped to `canvas`. `None` when the point is off the +/// canvas (or not finite), or `size` is not one of [`SAMPLE_SIZES`]. +pub fn sample_rect(x: f64, y: f64, size: u32, canvas: Rect) -> Option { + if !(x.is_finite() && y.is_finite()) || !SAMPLE_SIZES.contains(&size) { + return None; + } + let (fx, fy) = (x.floor(), y.floor()); + if fx < f64::from(i32::MIN) || fx > f64::from(i32::MAX) || fy < f64::from(i32::MIN) || fy > f64::from(i32::MAX) { + return None; + } + let (px, py) = (fx as i32, fy as i32); + if !canvas.contains(px, py) { + return None; + } + let r = (size / 2) as i32; + let square = Rect::new(px.saturating_sub(r), py.saturating_sub(r), px.saturating_add(r).saturating_add(1), py.saturating_add(r).saturating_add(1)); + Some(square.intersect(&canvas)) +} + +/// Round `v` to what a channel of `depth` can store (32 bit keeps the float). +fn quantize(v: f32, depth: SampleType) -> f32 { + let mut b = [0u8; 4]; + photocraft_color::write_sample(&mut b, depth, 0, v); + photocraft_color::read_sample(&b, depth, 0) +} + +/// The alpha-weighted average of straight-alpha RGBA pixels `px`, taken in the channels of +/// colour model `mode` at bit depth `depth`: each pixel is converted to `mode`, the colour +/// channels are averaged weighted by alpha (so transparent pixels don't darken the result), the +/// mean is rounded to `depth` and converted back to RGBA. Alpha is the mean coverage. Fully +/// transparent (or empty) input averages to transparent black. +/// +/// CMYK conversions use the CMYK space active on this thread +/// (`photocraft_color::convert::with_cmyk_space`). +pub fn average(px: &[[f32; 4]], mode: ColorMode, depth: SampleType) -> [f32; 4] { + let fmt = PixelFormat::new(mode, depth, false); + let n = fmt.channels().min(8); + let mut sum = [0.0f64; 8]; + let mut alpha = 0.0f64; + let mut native = [0.0f32; 8]; + for p in px { + let a = if p[3].is_finite() { p[3].clamp(0.0, 1.0) } else { 0.0 }; + if a <= 0.0 || p[..3].iter().any(|v| !v.is_finite()) { + continue; + } + let k = photocraft_raster::from_rgba_into(&fmt, *p, &mut native).min(n); + for (s, v) in sum.iter_mut().zip(&native[..k]) { + *s += f64::from(*v) * f64::from(a); + } + alpha += f64::from(a); + } + if px.is_empty() || alpha <= 0.0 { + return [0.0; 4]; + } + let mean: Vec = sum[..n].iter().map(|s| quantize((s / alpha) as f32, depth)).collect(); + let [r, g, b, _] = photocraft_raster::to_rgba(&fmt, &mean); + [r, g, b, quantize((alpha / px.len() as f64) as f32, depth)] +} + +/// The document that renders what `which` samples: the whole document, or a copy with the layers +/// it leaves out hidden. `active` is the active layer (`None`: Current Layer samples nothing). +fn sampled_document(doc: &Document, active: Option, which: SampleLayers) -> Option> { + use std::borrow::Cow; + match which { + SampleLayers::All => return Some(Cow::Borrowed(doc)), + SampleLayers::Current => { + // The active layer on its own, as if it were the only layer: its pixels, not how its + // group, blend mode or opacity show it in the composite. + let mut layer = doc.layer(active?)?.clone(); + layer.visible = true; + layer.clipped = false; + layer.opacity = 1.0; + layer.fill_opacity = 1.0; + layer.blend = photocraft_color::BlendMode::Normal; + let mut solo = doc.clone(); + solo.layers = vec![layer]; + return Some(Cow::Owned(solo)); + } + _ => {} + } + let mut copy = doc.clone(); + let walk: Vec<(Vec, LayerId, bool)> = doc.walk().into_iter().map(|(p, _, l)| (p, l.id, matches!(l.content, LayerContent::Adjustment(_)))).collect(); + if matches!(which, SampleLayers::CurrentAndBelow | SampleLayers::CurrentAndBelowNoAdjustments) { + // Everything composited after (above) the active layer in the bottom-to-top walk, except + // its own contents: the walk lists a group before its children. + let pos = walk.iter().position(|(_, id, _)| Some(*id) == active)?; + let own = walk.get(pos).map(|(p, _, _)| p.clone()).unwrap_or_default(); + for (path, _, _) in walk.get(pos + 1..).unwrap_or_default().iter().filter(|(p, _, _)| !p.starts_with(&own)) { + if let Some(l) = copy.layer_at_mut(path) { + l.visible = false; + } + } + } + if which.no_adjustments() { + for (path, _, _) in walk.iter().filter(|(_, _, adj)| *adj) { + if let Some(l) = copy.layer_at_mut(path) { + l.visible = false; + } + } + } + Some(Cow::Owned(copy)) +} + +/// The colour the Eyedropper picks at document point (`x`, `y`) with Sample Size `size` (see +/// [`SAMPLE_SIZES`]) from the layers `which` names, as straight-alpha RGBA. A Point Sample is the +/// composite pixel itself; larger sizes are the [`average`] of the square around it, clipped to +/// the canvas. Transparent black off the canvas, over transparency, or when `which` needs an +/// active layer and there is none. +pub fn sample_color(doc: &Document, active: Option, x: f64, y: f64, size: u32, which: SampleLayers) -> [f32; 4] { + let Some(rect) = sample_rect(x, y, size, doc.bounds()) else { return [0.0; 4] }; + let Some(src) = sampled_document(doc, active, which) else { return [0.0; 4] }; + let buf = photocraft_compose::render(&src, rect); + if size == 1 { + return buf.px.first().copied().unwrap_or_default(); + } + average(&buf.px, averaging_model(doc), doc.depth) +} + +/// The colour model the averages are taken in: the document's, except CMYK. CMYK documents are +/// composited in RGB (see `photocraft_compose`), and the profile's RGB → CMYK → RGB round trip +/// is not exact (deep shadows move by several levels), so averaging in CMYK would make a 3×3 +/// sample of a flat area differ from its Point Sample. Their composite RGB is averaged instead. +fn averaging_model(doc: &Document) -> ColorMode { + match doc.pixel_format().mode { + ColorMode::Cmyk => ColorMode::Rgb, + m => m, + } +} + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +/// The `size` param: 1 (Point Sample) or an averaged square's side; "point" is accepted too. +pub fn size_param(cmd: &str, p: &Value) -> Result { + match p.get("size") { + None | Some(Value::Null) => Ok(1), + Some(Value::String(s)) if s == "point" => Ok(1), + Some(v) => v + .as_u64() + .and_then(|n| u32::try_from(n).ok()) + .filter(|n| SAMPLE_SIZES.contains(n)) + .ok_or_else(|| bad(cmd, format!("`size` = {v} must be \"point\" or one of {SAMPLE_SIZES:?}"))), + } +} + +/// The `sampleLayer` param (default `all`). +pub fn layers_param(cmd: &str, p: &Value) -> Result { + match p.get("sampleLayer") { + None | Some(Value::Null) => Ok(SampleLayers::All), + Some(Value::String(s)) => SampleLayers::parse(s).ok_or_else(|| { + let ids: Vec<&str> = SampleLayers::ALL.iter().map(|(_, n)| *n).collect(); + bad(cmd, format!("unknown sampleLayer `{s}` ({})", ids.join("|"))) + }), + Some(v) => Err(bad(cmd, format!("`sampleLayer` = {v} must be a string"))), + } +} + +fn coord(cmd: &str, p: &Value, key: &str) -> Result { + p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number (document pixels)"))) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: "document.sampleColor", + label: "Sample Color", + menu: &[], + shortcut: None, + params: r##"{"x":f64,"y":f64,"size":"point"|1|3|5|11|31|51|101=1,"sampleLayer":"current"|"currentAndBelow"|"all"|"allNoAdjustments"|"currentAndBelowNoAdjustments"="all"} → [r,g,b,a] (the Eyedropper's Sample Size and Sample: the composite pixel, or the average of the size×size square centred on it, clipped to the canvas; transparent off the canvas)"##, + enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), + run: |s: &mut Session, p: &Value| { + const CMD: &str = "document.sampleColor"; + let (x, y) = (coord(CMD, p, "x")?, coord(CMD, p, "y")?); + let size = size_param(CMD, p)?; + let which = layers_param(CMD, p)?; + let d = s.active().ok_or(EngineError::NoDocument)?; + Ok(json!(sample_color(&d.doc, d.active_layer, x, y, size, which))) + }, + journal: false, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::{Adjustment, Layer, Size}; + + /// A `w`×`h` document of `mode`/`depth` whose background is `px(x, y)` (RGBA, converted to + /// the document's channels). + fn doc(w: u32, h: u32, mode: ColorMode, depth: SampleType, px: impl Fn(i32, i32) -> [f32; 4]) -> Document { + let mut d = Document::new("t", Size::new(w, h), mode, depth); + let mut bg = Layer::raster("Background", d.pixel_format()); + let s = bg.surface_mut().unwrap(); + let fmt = s.format(); + for y in 0..h as i32 { + for x in 0..w as i32 { + s.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, px(x, y))); + } + } + d.layers.push(bg); + d + } + + fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool { + a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) + } + + #[test] + fn sample_rect_centres_the_square_and_clips_to_the_canvas() { + let canvas = Rect::new(0, 0, 20, 10); + assert_eq!(sample_rect(5.5, 5.5, 1, canvas), Some(Rect::new(5, 5, 6, 6))); + assert_eq!(sample_rect(5.9, 5.1, 3, canvas), Some(Rect::new(4, 4, 7, 7))); + assert_eq!(sample_rect(10.0, 5.0, 5, canvas), Some(Rect::new(8, 3, 13, 8))); + // Corners and edges: the square is clipped. + assert_eq!(sample_rect(0.0, 0.0, 5, canvas), Some(Rect::new(0, 0, 3, 3))); + assert_eq!(sample_rect(19.5, 9.5, 11, canvas), Some(Rect::new(14, 4, 20, 10))); + assert_eq!(sample_rect(10.0, 5.0, 101, canvas), Some(canvas)); + // Off the canvas, non-finite, or not a Photoshop size: nothing. + assert_eq!(sample_rect(-0.5, 3.0, 3, canvas), None); + assert_eq!(sample_rect(20.0, 3.0, 3, canvas), None); + assert_eq!(sample_rect(f64::NAN, 3.0, 3, canvas), None); + assert_eq!(sample_rect(1e300, 3.0, 3, canvas), None); + assert_eq!(sample_rect(3.0, 3.0, 4, canvas), None); + assert_eq!(sample_rect(3.0, 3.0, 0, canvas), None); + // At the i32 limits the square saturates rather than overflowing. + let huge = Rect::new(i32::MAX - 4, i32::MAX - 4, i32::MAX, i32::MAX); + assert_eq!(sample_rect(f64::from(i32::MAX - 1), f64::from(i32::MAX - 1), 101, huge), Some(huge)); + } + + #[test] + fn average_weights_by_alpha_and_ignores_garbage() { + let px = [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 1.0]]; + let a = average(&px, ColorMode::Rgb, SampleType::F32); + assert!(close(a, [0.5, 0.0, 0.5, 0.5], 1e-6), "{a:?}"); + // Half-transparent red next to opaque blue: red counts half. + let a = average(&[[1.0, 0.0, 0.0, 0.5], [0.0, 0.0, 1.0, 1.0]], ColorMode::Rgb, SampleType::F32); + assert!(close(a, [1.0 / 3.0, 0.0, 2.0 / 3.0, 0.75], 1e-6), "{a:?}"); + assert_eq!(average(&[], ColorMode::Rgb, SampleType::U8), [0.0; 4]); + assert_eq!(average(&[[0.3, 0.3, 0.3, 0.0]], ColorMode::Rgb, SampleType::U8), [0.0; 4]); + } + + #[test] + fn average_rounds_to_the_document_depth() { + // 0, 0.1, 1 averages to 0.3667: 94/255 at 8 bit, 24030/65535 at 16 bit, the float at 32. + let px = [[0.0, 0.0, 0.0, 1.0], [0.1, 0.1, 0.1, 1.0], [1.0, 1.0, 1.0, 1.0]]; + let a8 = average(&px, ColorMode::Rgb, SampleType::U8); + assert_eq!(a8[0], 94.0 / 255.0); + let a16 = average(&px, ColorMode::Rgb, SampleType::U16); + assert_eq!(a16[0], 24030.0 / 65535.0); + let a32 = average(&px, ColorMode::Rgb, SampleType::F32); + assert!((a32[0] - 1.1 / 3.0).abs() < 1e-6); + assert!(a8[0] != a32[0] && a16[0] != a32[0]); + } + + #[test] + fn average_is_taken_in_the_document_colour_model() { + // Gray: the mean of the gray values. + let a = average(&[[0.2, 0.2, 0.2, 1.0], [0.6, 0.6, 0.6, 1.0]], ColorMode::Grayscale, SampleType::F32); + assert!(close(a, [0.4, 0.4, 0.4, 1.0], 1e-5), "{a:?}"); + // CMYK and Lab: the mean of the CMYK / Lab values, not of the RGB ones. + let (red, blue) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]); + for mode in [ColorMode::Cmyk, ColorMode::Lab] { + let fmt = PixelFormat::new(mode, SampleType::F32, false); + let (r, b) = (photocraft_raster::from_rgba(&fmt, red), photocraft_raster::from_rgba(&fmt, blue)); + let mean: Vec = r.iter().zip(&b).map(|(x, y)| (x + y) / 2.0).collect(); + let want = photocraft_raster::to_rgba(&fmt, &mean); + let got = average(&[red, blue], mode, SampleType::F32); + assert!(close(got, want, 1e-5), "{mode:?}: {got:?} vs {want:?}"); + assert!(!close(got, [0.5, 0.0, 0.5, 1.0], 0.02), "{mode:?} averaged in RGB: {got:?}"); + } + } + + #[test] + fn averages_use_the_document_model_except_cmyk() { + let model = |mode| averaging_model(&Document::new("m", Size::new(1, 1), mode, SampleType::U8)); + assert_eq!(model(ColorMode::Rgb), ColorMode::Rgb); + assert_eq!(model(ColorMode::Grayscale), ColorMode::Grayscale); + assert_eq!(model(ColorMode::Lab), ColorMode::Lab); + assert_eq!(model(ColorMode::Bitmap), ColorMode::Grayscale); + assert_eq!(model(ColorMode::Cmyk), ColorMode::Rgb); + } + + /// A 9×9 document: white, except a black 3×3 block at (0..3, 0..3). + fn block(mode: ColorMode, depth: SampleType) -> Document { + doc(9, 9, mode, depth, |x, y| if x < 3 && y < 3 { [0.0, 0.0, 0.0, 1.0] } else { [1.0; 4] }) + } + + #[test] + fn sample_sizes_average_the_square_around_the_pixel_at_every_depth_and_mode() { + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for depth in SampleType::ALL { + let d = block(mode, depth); + let all = SampleLayers::All; + let tol = if depth == SampleType::U8 { 1.5 / 255.0 } else { 2e-3 }; + // Point Sample: the composite pixel itself (black, as the document shows it). + let p = sample_color(&d, None, 1.5, 1.5, 1, all); + let white = sample_color(&d, None, 7.0, 7.0, 1, all); + assert!(p[0] < 0.05 && p[3] == 1.0, "{mode:?} {depth:?} point {p:?}"); + // 3×3 at (3, 3): one black pixel of nine. Gray, RGB: 8/9 white. + let s = sample_color(&d, None, 3.5, 3.5, 3, all); + let want = sample_color(&d, None, 3.5, 3.5, 1, all); + assert!(s[0] < want[0] && s[3] == 1.0, "{mode:?} {depth:?} 3x3 {s:?}"); + if matches!(mode, ColorMode::Rgb | ColorMode::Grayscale) { + assert!(close(s, [8.0 / 9.0, 8.0 / 9.0, 8.0 / 9.0, 1.0], tol), "{mode:?} {depth:?} 3x3 {s:?}"); + } + // 5×5 at the corner: clipped to 3×3, all black. + let c = sample_color(&d, None, 0.0, 0.0, 5, all); + assert!(close(c, p, tol), "{mode:?} {depth:?} corner {c:?} vs {p:?}"); + // 101×101 covers the canvas: 9 of 81 black. + if matches!(mode, ColorMode::Rgb | ColorMode::Grayscale) { + let w = sample_color(&d, None, 4.0, 4.0, 101, all); + assert!(close(w, [72.0 / 81.0, 72.0 / 81.0, 72.0 / 81.0, 1.0], tol), "{mode:?} {depth:?} 101 {w:?}"); + } + // A uniform area averages to its own colour (round trips through the model). + let u = sample_color(&d, None, 7.0, 7.0, 3, all); + assert!(close(u, white, tol), "{mode:?} {depth:?} uniform {u:?} vs {white:?}"); + // Off the canvas: nothing. + assert_eq!(sample_color(&d, None, -1.0, 4.0, 11, all), [0.0; 4]); + } + } + } + + #[test] + fn current_and_below_keeps_an_active_groups_contents() { + // A white background and a group holding a red layer; the group is the active layer. + let mut d = doc(3, 3, ColorMode::Rgb, SampleType::U8, |_, _| [1.0; 4]); + let mut red = Layer::raster("red", d.pixel_format()); + red.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 3, 3), &[1.0, 0.0, 0.0, 1.0]); + let group = Layer::group("group", vec![red]); + let gid = group.id; + d.layers.push(group); + for which in [SampleLayers::CurrentAndBelow, SampleLayers::CurrentAndBelowNoAdjustments] { + let c = sample_color(&d, Some(gid), 1.5, 1.5, 1, which); + assert!(close(c, [1.0, 0.0, 0.0, 1.0], 1.5 / 255.0), "{which:?}: {c:?}"); + } + } + + #[test] + fn sample_layers_choose_what_is_averaged() { + // Background white; a red 3x3 layer in the middle; an Invert adjustment on top. + let mut d = doc(9, 9, ColorMode::Rgb, SampleType::U8, |_, _| [1.0; 4]); + let bg = d.layers[0].id; + let mut red = Layer::raster("red", d.pixel_format()); + red.surface_mut().unwrap().fill_rect(Rect::new(3, 3, 6, 6), &[1.0, 0.0, 0.0, 1.0]); + let red = d.insert_above(Some(bg), red); + let inv = d.insert_above(Some(red), Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))); + let (cx, cy) = (4.5, 4.5); + // 5×5 around the centre: 9 red pixels of 25. + let s = |d: &Document, active, which| sample_color(d, active, cx, cy, 5, which); + let close8 = |a: [f32; 4], b: [f32; 4]| close(a, b, 1.5 / 255.0); + // Current Layer = the red layer alone: red, 9/25 covered. + let cur = s(&d, Some(red), SampleLayers::Current); + assert!(close8(cur, [1.0, 0.0, 0.0, 9.0 / 25.0]), "{cur:?}"); + // Current & Below: red over white, not inverted. + let below = s(&d, Some(red), SampleLayers::CurrentAndBelow); + assert!(close8(below, [1.0, 16.0 / 25.0, 16.0 / 25.0, 1.0]), "{below:?}"); + // All Layers: inverted (cyan over black). + let all = s(&d, Some(red), SampleLayers::All); + assert!(close8(all, [0.0, 9.0 / 25.0, 9.0 / 25.0, 1.0]), "{all:?}"); + // No Adjustments: as if the Invert were hidden. + assert!(close8(s(&d, Some(red), SampleLayers::AllNoAdjustments), below)); + assert!(close8(s(&d, Some(inv), SampleLayers::CurrentAndBelowNoAdjustments), below)); + assert!(close8(s(&d, Some(inv), SampleLayers::CurrentAndBelow), all)); + // Current Layer with an adjustment layer or no active layer: nothing to sample. + assert_eq!(s(&d, Some(inv), SampleLayers::Current), [0.0; 4]); + assert_eq!(s(&d, None, SampleLayers::Current), [0.0; 4]); + assert_eq!(s(&d, None, SampleLayers::CurrentAndBelow), [0.0; 4]); + // Current Layer ignores how the composite shows the layer: hidden, 50% opacity, Multiply. + let l = d.layer_mut(red).unwrap(); + l.visible = false; + l.opacity = 0.5; + l.blend = photocraft_color::BlendMode::Multiply; + assert!(close8(s(&d, Some(red), SampleLayers::Current), cur)); + } + + #[test] + fn the_command_validates_its_params() { + let mut s = Session::new(); + assert!(s.execute("document.sampleColor", json!({"x": 1, "y": 1})).is_err(), "no document"); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + let v: Vec = serde_json::from_value(s.execute("document.sampleColor", json!({"x": 4, "y": 4, "size": 3})).unwrap()).unwrap(); + assert_eq!(v, vec![1.0; 4]); + let v: Vec = + serde_json::from_value(s.execute("document.sampleColor", json!({"x": 4, "y": 4, "size": "point", "sampleLayer": "current"})).unwrap()).unwrap(); + assert_eq!(v, vec![1.0; 4]); + let v: Vec = serde_json::from_value(s.execute("document.sampleColor", json!({"x": -40, "y": 4, "size": 101})).unwrap()).unwrap(); + assert_eq!(v, vec![0.0; 4]); + for bad in [ + json!({}), + json!({"x": 1}), + json!({"x": "a", "y": 1}), + json!({"x": 1, "y": 1, "size": 4}), + json!({"x": 1, "y": 1, "size": -3}), + json!({"x": 1, "y": 1, "size": "huge"}), + json!({"x": 1, "y": 1, "size": 1e40}), + json!({"x": 1, "y": 1, "sampleLayer": "below"}), + json!({"x": 1, "y": 1, "sampleLayer": 3}), + json!([1, 2]), + ] { + assert!(matches!(s.execute("document.sampleColor", bad.clone()), Err(EngineError::BadParams { .. })), "{bad}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bb7bcfa042..1c6053509f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -396,7 +396,7 @@ fn display_color(c: [f32; 3]) -> Color32 { /// (what a click would pick) and the current foreground, or `None` where there is no colour /// to sample (#213). pub(crate) fn eyedropper_ring_colors(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<(Color32, Color32)> { - let new = composite_color(app, x, y)?; + let new = eyedropper_color(app, x, y)?; let fg = app.session.tools.foreground; Some((display_color(new), display_color([fg[0], fg[1], fg[2]]))) } @@ -407,7 +407,8 @@ pub(crate) fn eyedropper_ring_colors(app: &mut PhotocraftApp, x: f64, y: f64) -> /// crosshair and samples nothing. `None` when not held, there is nothing to sample, or the /// Precise-cursor preference wants the plain crosshair. fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, p: Pos2, held: bool) -> Option { - if !held || app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { + // The options bar's Show Sampling Ring turns it off (#1649). + if !held || !app.ui.tool_options.eyedropper_ring || app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { return None; } let [x, y] = xf.to_doc(p); @@ -3138,25 +3139,41 @@ fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) { - if let Some([r, g, b]) = composite_color(app, x, y) { + if let Some([r, g, b]) = eyedropper_color(app, x, y) { let key = if mods.alt { "background" } else { "foreground" }; let _ = app.run("tools.setColors", json!({ key: [r, g, b, 1.0] })); } } -/// The active document's composite colour at document point (x, y): what the Eyedropper picks. +/// The colour at document point (x, y) with the Eyedropper's Sample Size (the composite pixel, +/// or the average of the square around it) from the layers `sample_layer` names (`document.sampleColor`). /// `None` off the image or over transparency. -pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<[f32; 3]> { +fn sample_color(app: &mut PhotocraftApp, x: f64, y: f64, sample_layer: &str) -> Option<[f32; 3]> { if !(x.is_finite() && y.is_finite()) { return None; } - let v = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})).ok()?; + let size = app.ui.tool_options.eyedropper_size; + let v = app.run("document.sampleColor", json!({"x": x, "y": y, "size": size, "sampleLayer": sample_layer})).ok()?; match serde_json::from_value::>(v).ok()?[..] { [r, g, b, a] if a > 0.0 => Some([r, g, b]), _ => None, } } +/// What the Eyedropper tool (and a painting tool's ⌥-click) picks at document point (x, y): its +/// options bar's Sample Size and Sample (#1649). +pub(crate) fn eyedropper_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<[f32; 3]> { + let layers = app.ui.tool_options.eyedropper_sample.clone(); + sample_color(app, x, y, &layers) +} + +/// The active document's composite colour at document point (x, y), averaged over the +/// Eyedropper's Sample Size as Photoshop's dialog eyedroppers (Curves, Color Picker) do. +/// `None` off the image or over transparency. +pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<[f32; 3]> { + sample_color(app, x, y, "all") +} + /// The body of a `Move` for every tool: tracked position, ⇧ constraint, the moving layer, and so /// on. It does not feed the live stroke, so the canvas can push a whole frame's recovered samples /// and update the live stroke once (see `canvas_view`). diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 74a3df96ed..7de5926ed1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -74,7 +74,7 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 22] = [ +pub const UI_SET_FIELDS: [&str; 25] = [ "tool", "panels", "dock", @@ -97,6 +97,9 @@ pub const UI_SET_FIELDS: [&str; 22] = [ "brushSize", "gradientBlendMode", "gradientClassic", + "eyedropperSampleSize", + "eyedropperSample", + "eyedropperRing", ]; /// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app @@ -298,6 +301,23 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) None => None, }; let gradient_classic = bool_field(p, "gradientClassic")?; + // The Eyedropper's options bar (#1649): Sample Size, Sample, Show Sampling Ring. + let eyedropper_size = match p.get("eyedropperSampleSize") { + Some(v) => Some( + photocraft_engine::sample_cmds::size_param("ui.set", &json!({"size": v})) + .map_err(|_| format!("eyedropperSampleSize must be \"point\" or one of {:?}", photocraft_engine::sample_cmds::SAMPLE_SIZES))?, + ), + None => None, + }; + let eyedropper_sample = match p.get("eyedropperSample") { + Some(v) => { + let ids: Vec<&str> = photocraft_engine::sample_cmds::SampleLayers::ALL.iter().map(|(_, n)| *n).collect(); + let id = v.as_str().filter(|id| ids.contains(id)).ok_or_else(|| format!("eyedropperSample must be one of {}", ids.join(", ")))?; + Some(id.to_string()) + } + None => None, + }; + let eyedropper_ring = bool_field(p, "eyedropperRing")?; let panels = merged_object(&app.ui.panels, p.get("panels"), "panels")?; let mask_target = bool_field(p, "maskTarget")?; let vector_mask_target = bool_field(p, "vectorMaskTarget")?; @@ -389,6 +409,15 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if gradient_blend.is_some() { crate::gradient_ui::options_changed(app, &gradient_before); } + if let Some(size) = eyedropper_size { + app.ui.tool_options.eyedropper_size = size; + } + if let Some(id) = eyedropper_sample { + app.ui.tool_options.eyedropper_sample = id; + } + if let Some(ring) = eyedropper_ring { + app.ui.tool_options.eyedropper_ring = ring; + } if let Some(v) = panels { app.ui.panels = v; } @@ -976,6 +1005,37 @@ mod tests { assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options"); } + #[test] + fn ui_set_drives_the_eyedropper_options() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let good = call( + &mut app, + &ctx, + "ui.set", + json!({"tool": "eyedropper", "eyedropperSampleSize": 11, "eyedropperSample": "currentAndBelow", "eyedropperRing": false}), + ); + assert_eq!(good["ok"], true, "{good}"); + let o = &app.ui.tool_options; + assert_eq!((o.eyedropper_size, o.eyedropper_sample.as_str(), o.eyedropper_ring), (11, "currentAndBelow", false)); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"eyedropperSampleSize": "point"}))["ok"], true); + assert_eq!(app.ui.tool_options.eyedropper_size, 1); + for bad in [ + json!({"eyedropperSampleSize": 4}), + json!({"eyedropperSampleSize": "big"}), + json!({"eyedropperSample": "below"}), + json!({"eyedropperSample": 2}), + json!({"eyedropperRing": "yes"}), + ] { + let r = call(&mut app, &ctx, "ui.set", bad.clone()); + assert_eq!(r["ok"], false, "{bad}: {r}"); + } + // A rejected call applies none of its fields. + let r = call(&mut app, &ctx, "ui.set", json!({"eyedropperSampleSize": 101, "eyedropperSample": "nope"})); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(app.ui.tool_options.eyedropper_size, 1); + } + #[test] fn ui_set_opens_the_brush_preset_picker_and_sets_its_view() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/eyedropper_ui.rs b/crates/ui-egui/src/eyedropper_ui.rs new file mode 100644 index 0000000000..4b50d4c33a --- /dev/null +++ b/crates/ui-egui/src/eyedropper_ui.rs @@ -0,0 +1,203 @@ +//! The Eyedropper tool's options bar (#1649): Sample Size (Point Sample or an N by N Average), +//! Sample (which layers) and Show Sampling Ring, as in Photoshop. The values live in +//! `state::ToolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`), so the +//! control channel can read and set them; sampling itself is `document.sampleColor` +//! (`canvas::eyedropper_color`). The Sample Size is shared with the painting tools' ⌥-click +//! eyedropper, the dialog eyedroppers and the Info panel, as in Photoshop. + +use crate::PhotocraftApp; +use crate::theme::Tokens; +use crate::widgets; + +/// The Sample Size menu: each size with its label, in Photoshop's order. +pub fn size_options() -> [(u32, &'static str); 7] { + [ + (1, tl!("Point Sample")), + (3, tl!("3 by 3 Average")), + (5, tl!("5 by 5 Average")), + (11, tl!("11 by 11 Average")), + (31, tl!("31 by 31 Average")), + (51, tl!("51 by 51 Average")), + (101, tl!("101 by 101 Average")), + ] +} + +/// The Sample menu: `document.sampleColor`'s `sampleLayer` ids with their labels. +pub fn layer_options() -> [(String, &'static str); 5] { + [ + ("current".into(), tl!("Current Layer")), + ("currentAndBelow".into(), tl!("Current & Below")), + ("all".into(), tl!("All Layers")), + ("allNoAdjustments".into(), tl!("All Layers No Adjustments")), + ("currentAndBelowNoAdjustments".into(), tl!("Current & Below No Adjustments")), + ] +} + +fn opt(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).color(t.text_dim).size(12.0)); +} + +/// The options bar for the Eyedropper tool. +pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let o = &mut app.ui.tool_options; + opt(ui, tl!("Sample Size:")); + widgets::dropdown(ui, "eyedropper-size", &mut o.eyedropper_size, &size_options(), 130.0); + opt(ui, tl!("Sample:")); + widgets::dropdown(ui, "eyedropper-sample", &mut o.eyedropper_sample, &layer_options(), 190.0); + widgets::checkbox(ui, &mut o.eyedropper_ring, tl!("Show Sampling Ring")); + widgets::vline(ui, 22.0); + let t = Tokens::get(ui.ctx()); + ui.label( + egui::RichText::new(crate::i18n::fmt( + tl!("Click to sample the foreground colour · {key}-click for background"), + &[("key", &crate::shortcuts::pretty("Alt"))], + )) + .color(t.text_faint), + ); +} + +#[cfg(test)] +mod tests { + use egui::vec2; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + use serde_json::json; + + use crate::PhotocraftApp; + use crate::canvas::{ToolEvent, tool_event}; + use crate::state::Tool; + + /// An app with a 40×40 white document whose top-left 3×3 pixels are black. + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 40, "background": "white"})).unwrap(); + app.run("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0]})).unwrap(); + app.session + .edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&bg.format(), [0.0, 0.0, 0.0, 1.0]); + bg.fill_rect(photocraft_geom::Rect::new(0, 0, 3, 3), &black); + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::Eyedropper; + app + } + + fn fg(app: &PhotocraftApp) -> [f32; 4] { + app.session.tools.foreground + } + + fn click(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x, y }, mods); + } + + #[test] + fn a_click_samples_the_average_of_the_sample_size() { + let mut app = app(); + // Point Sample at (3, 3): white. + click(&mut app, 3.5, 3.5, egui::Modifiers::NONE); + assert_eq!(fg(&app), [1.0; 4]); + // 3 by 3 Average: one black pixel of nine, rounded to 8 bit. + app.ui.tool_options.eyedropper_size = 3; + click(&mut app, 3.5, 3.5, egui::Modifiers::NONE); + let want = (255.0f32 * 8.0 / 9.0).round() / 255.0; + assert_eq!(fg(&app), [want, want, want, 1.0]); + // 5 by 5 at the corner: clipped to the black 3×3. + app.ui.tool_options.eyedropper_size = 5; + click(&mut app, 0.5, 0.5, egui::Modifiers::NONE); + assert_eq!(fg(&app), [0.0, 0.0, 0.0, 1.0]); + // ⌥-click sets the background colour, with the same size. + app.ui.tool_options.eyedropper_size = 3; + click(&mut app, 3.5, 3.5, egui::Modifiers::ALT); + assert_eq!(app.session.tools.background, [want, want, want, 1.0]); + // The painting tools' ⌥-click eyedropper uses it too. + app.ui.tool = Tool::Brush; + app.ui.tool_options.eyedropper_size = 101; + click(&mut app, 20.0, 20.0, egui::Modifiers::ALT); + let want = (255.0f32 * (1600.0 - 9.0) / 1600.0).round() / 255.0; + assert_eq!(fg(&app), [want, want, want, 1.0]); + // The dialog eyedroppers (and the Color Picker's) average the same square. + app.ui.tool_options.eyedropper_size = 5; + let c = crate::canvas::composite_color(&mut app, 1.0, 1.0).unwrap(); + let want = (255.0f32 * 7.0 / 16.0).round() / 255.0; + assert_eq!(c, [want; 3]); + } + + #[test] + fn the_sample_menu_picks_the_layers() { + let mut app = app(); + // A new empty layer on top: Current Layer finds nothing; All Layers the background. + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool_options.eyedropper_size = 3; + app.ui.tool_options.eyedropper_sample = "current".into(); + app.run("tools.setColors", json!({"foreground": [0.2, 0.4, 0.6, 1.0]})).unwrap(); + click(&mut app, 10.0, 10.0, egui::Modifiers::NONE); + assert_eq!(fg(&app), [0.2, 0.4, 0.6, 1.0], "nothing to sample on an empty layer"); + app.ui.tool_options.eyedropper_sample = "all".into(); + click(&mut app, 10.0, 10.0, egui::Modifiers::NONE); + assert_eq!(fg(&app), [1.0; 4]); + } + + #[test] + fn tool_options_round_trip_through_serde_with_defaults() { + let o = crate::state::ToolOptions::default(); + assert_eq!((o.eyedropper_size, o.eyedropper_sample.as_str(), o.eyedropper_ring), (1, "all", true)); + // Older saved state without the fields loads with Photoshop's defaults. + let old: crate::state::ToolOptions = serde_json::from_value(json!({"tolerance": 12.0})).unwrap(); + assert_eq!((old.eyedropper_size, old.eyedropper_sample.as_str(), old.eyedropper_ring), (1, "all", true)); + let mut o = o; + o.eyedropper_size = 31; + o.eyedropper_sample = "currentAndBelow".into(); + o.eyedropper_ring = false; + let back: crate::state::ToolOptions = serde_json::from_value(serde_json::to_value(&o).unwrap()).unwrap(); + assert_eq!(back, o); + // Every menu entry is a size and a layer choice the engine accepts. + for (size, _) in super::size_options() { + assert!(photocraft_engine::sample_cmds::SAMPLE_SIZES.contains(&size)); + } + for (id, _) in super::layer_options() { + assert!(photocraft_engine::sample_cmds::SampleLayers::parse(&id).is_some(), "{id}"); + } + } + + /// The options bar: the Sample Size dropdown sets the size, and the next click on the canvas + /// samples that average. + #[test] + fn the_options_bar_dropdown_sets_the_sample_size() { + let mut h = Harness::builder().with_size(vec2(1400.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + for label in ["Sample Size:", "Sample:", "Show Sampling Ring"] { + assert!(h.get_by_label(label).rect().is_positive(), "missing Eyedropper option {label}"); + } + let combo = |h: &Harness<'static, PhotocraftApp>, i: usize| h.get_all_by_role(egui::accesskit::Role::ComboBox).nth(i).unwrap().click(); + combo(&h, 0); + h.run_steps(3); + h.get_by_label("5 by 5 Average").click(); + h.run_steps(3); + assert_eq!(h.state().ui.tool_options.eyedropper_size, 5); + combo(&h, 1); + h.run_steps(3); + h.get_by_label("Current Layer").click(); + h.run_steps(3); + assert_eq!(h.state().ui.tool_options.eyedropper_sample, "current"); + h.get_by_label("Show Sampling Ring").click(); + h.run_steps(3); + assert!(!h.state().ui.tool_options.eyedropper_ring); + // A click at (1, 3) now averages the 5×5 around it: rows 1..6 × cols 0..4 hold 6 black. + click(h.state_mut(), 1.5, 3.5, egui::Modifiers::NONE); + let want = (255.0f32 * 14.0 / 20.0).round() / 255.0; + assert_eq!(fg(h.state()), [want, want, want, 1.0]); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 381635c986..08593603f7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2252,3 +2252,16 @@ fontWeight Italic Kurzíva # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Bodový vzorek + 3 by 3 Average Průměr 3 × 3 + 5 by 5 Average Průměr 5 × 5 + 11 by 11 Average Průměr 11 × 11 + 31 by 31 Average Průměr 31 × 31 + 51 by 51 Average Průměr 51 × 51 + 101 by 101 Average Průměr 101 × 101 + Sample Size: Velikost vzorku: + All Layers No Adjustments Všechny vrstvy bez korekcí + Current & Below No Adjustments Aktuální a pod ní bez korekcí + Show Sampling Ring Zobrazit vzorkovací kroužek diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 96a3d34b0c..79c463a12a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2291,3 +2291,16 @@ fontWeight Italic Kursiv # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Punktprobe + 3 by 3 Average 3 × 3 Durchschnitt + 5 by 5 Average 5 × 5 Durchschnitt + 11 by 11 Average 11 × 11 Durchschnitt + 31 by 31 Average 31 × 31 Durchschnitt + 51 by 51 Average 51 × 51 Durchschnitt + 101 by 101 Average 101 × 101 Durchschnitt + Sample Size: Probengröße: + All Layers No Adjustments Alle Ebenen ohne Anpassungen + Current & Below No Adjustments Aktuelle und darunter ohne Anpassungen + Show Sampling Ring Probenring anzeigen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 36ace41e2c..1d9404bdc1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3365,3 +3365,16 @@ fontWeight Thin Πολύ λεπτά # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Σημειακό δείγμα + 3 by 3 Average Μέσος όρος 3 × 3 + 5 by 5 Average Μέσος όρος 5 × 5 + 11 by 11 Average Μέσος όρος 11 × 11 + 31 by 31 Average Μέσος όρος 31 × 31 + 51 by 51 Average Μέσος όρος 51 × 51 + 101 by 101 Average Μέσος όρος 101 × 101 + Sample Size: Μέγεθος δείγματος: + All Layers No Adjustments Όλα τα επίπεδα χωρίς προσαρμογές + Current & Below No Adjustments Τρέχον και υποκείμενα χωρίς προσαρμογές + Show Sampling Ring Εμφάνιση δακτυλίου δειγματοληψίας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 969adb565b..e910be826d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2321,3 +2321,16 @@ fontWeight Italic Cursiva # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Muestra puntual + 3 by 3 Average Promedio de 3 × 3 + 5 by 5 Average Promedio de 5 × 5 + 11 by 11 Average Promedio de 11 × 11 + 31 by 31 Average Promedio de 31 × 31 + 51 by 51 Average Promedio de 51 × 51 + 101 by 101 Average Promedio de 101 × 101 + Sample Size: Tamaño de muestra: + All Layers No Adjustments Todas las capas sin ajustes + Current & Below No Adjustments Actual e inferiores sin ajustes + Show Sampling Ring Mostrar anillo de muestreo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ccf9307457..7e6160712e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2675,3 +2675,16 @@ fontWeight Italic Italique # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Échantillon ponctuel + 3 by 3 Average Moyenne 3 × 3 + 5 by 5 Average Moyenne 5 × 5 + 11 by 11 Average Moyenne 11 × 11 + 31 by 31 Average Moyenne 31 × 31 + 51 by 51 Average Moyenne 51 × 51 + 101 by 101 Average Moyenne 101 × 101 + Sample Size: Taille de l'échantillon : + All Layers No Adjustments Tous les calques sans ajustements + Current & Below No Adjustments Actif et inférieurs sans ajustements + Show Sampling Ring Afficher l'anneau d'échantillonnage diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9bfb0e832f..4f7fc6f657 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2662,3 +2662,16 @@ fontWeight Italic Miring # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Sampel Titik + 3 by 3 Average Rata-rata 3 × 3 + 5 by 5 Average Rata-rata 5 × 5 + 11 by 11 Average Rata-rata 11 × 11 + 31 by 31 Average Rata-rata 31 × 31 + 51 by 51 Average Rata-rata 51 × 51 + 101 by 101 Average Rata-rata 101 × 101 + Sample Size: Ukuran Sampel: + All Layers No Adjustments Semua Layer Tanpa Penyesuaian + Current & Below No Adjustments Saat Ini & Di Bawahnya Tanpa Penyesuaian + Show Sampling Ring Tampilkan Cincin Sampel diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 42020f6738..3ce4650982 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2322,3 +2322,16 @@ fontWeight Italic Corsivo # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Campione puntiforme + 3 by 3 Average Media 3 × 3 + 5 by 5 Average Media 5 × 5 + 11 by 11 Average Media 11 × 11 + 31 by 31 Average Media 31 × 31 + 51 by 51 Average Media 51 × 51 + 101 by 101 Average Media 101 × 101 + Sample Size: Dimensione campione: + All Layers No Adjustments Tutti i livelli senza regolazioni + Current & Below No Adjustments Corrente e inferiori senza regolazioni + Show Sampling Ring Mostra anello di campionamento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 87e7b02a7b..17823b2008 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2235,3 +2235,16 @@ fontWeight Italic 斜体 # Color Picker warnings Out of gamut for printing: click for the nearest printable colour 印刷の色域外:クリックすると最も近い印刷可能な色になります Not a web colour: click for the nearest web colour Web カラーではありません:クリックすると最も近い Web カラーになります + +# Eyedropper Sample Size and Sample (#1649). + Point Sample ポイントサンプル + 3 by 3 Average 3 × 3 平均 + 5 by 5 Average 5 × 5 平均 + 11 by 11 Average 11 × 11 平均 + 31 by 31 Average 31 × 31 平均 + 51 by 51 Average 51 × 51 平均 + 101 by 101 Average 101 × 101 平均 + Sample Size: サンプルサイズ: + All Layers No Adjustments すべてのレイヤー(色調補正なし) + Current & Below No Adjustments 現在のレイヤーとその下(色調補正なし) + Show Sampling Ring サンプリングリングを表示 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a54aeb3642..a71f5362ed 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2831,3 +2831,16 @@ fontWeight Italic 기울임 # Color Picker warnings Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다 Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다 + +# Eyedropper Sample Size and Sample (#1649). + Point Sample 포인트 샘플 + 3 by 3 Average 3 × 3 평균 + 5 by 5 Average 5 × 5 평균 + 11 by 11 Average 11 × 11 평균 + 31 by 31 Average 31 × 31 평균 + 51 by 51 Average 51 × 51 평균 + 101 by 101 Average 101 × 101 평균 + Sample Size: 샘플 크기: + All Layers No Adjustments 모든 레이어(조정 제외) + Current & Below No Adjustments 현재 및 이하(조정 제외) + Show Sampling Ring 샘플링 링 표시 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 216d6547ab..922a4200be 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3276,3 +3276,16 @@ cameraRaw Roughness Chropowatość # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Próbka punktowa + 3 by 3 Average Uśrednianie 3 × 3 + 5 by 5 Average Uśrednianie 5 × 5 + 11 by 11 Average Uśrednianie 11 × 11 + 31 by 31 Average Uśrednianie 31 × 31 + 51 by 51 Average Uśrednianie 51 × 51 + 101 by 101 Average Uśrednianie 101 × 101 + Sample Size: Wielkość próbki: + All Layers No Adjustments Wszystkie warstwy bez korekt + Current & Below No Adjustments Bieżąca i poniżej bez korekt + Show Sampling Ring Pokaż pierścień próbkowania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index cc8da39094..df56e42376 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2682,3 +2682,16 @@ fontWeight Italic Itálico # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Amostra pontual + 3 by 3 Average Média de 3 × 3 + 5 by 5 Average Média de 5 × 5 + 11 by 11 Average Média de 11 × 11 + 31 by 31 Average Média de 31 × 31 + 51 by 51 Average Média de 51 × 51 + 101 by 101 Average Média de 101 × 101 + Sample Size: Tamanho da amostra: + All Layers No Adjustments Todas as camadas sem ajustes + Current & Below No Adjustments Atual e abaixo sem ajustes + Show Sampling Ring Mostrar anel de amostragem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 45789b1c05..80160df12a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2523,3 +2523,17 @@ fontWeight Italic Курсив # Color Picker warnings Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Точечная выборка + 3 by 3 Average Среднее 3 × 3 + 5 by 5 Average Среднее 5 × 5 + 11 by 11 Average Среднее 11 × 11 + 31 by 31 Average Среднее 31 × 31 + 51 by 51 Average Среднее 51 × 51 + 101 by 101 Average Среднее 101 × 101 + Sample Size: Размер выборки: + All Layers No Adjustments Все слои без коррекции + Current & Below No Adjustments Текущий и ниже без коррекции + Show Sampling Ring Показать кольцо выборки + Click to sample the foreground colour · {key}-click for background Клик — взять основной цвет · {key}-клик — фоновый цвет diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eb7af072ce..37e86abacd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2260,3 +2260,16 @@ fontWeight Italic 斜体 # Color Picker warnings Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色 Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色 + +# Eyedropper Sample Size and Sample (#1649). + Point Sample 采样点 + 3 by 3 Average 3 × 3 平均 + 5 by 5 Average 5 × 5 平均 + 11 by 11 Average 11 × 11 平均 + 31 by 31 Average 31 × 31 平均 + 51 by 51 Average 51 × 51 平均 + 101 by 101 Average 101 × 101 平均 + Sample Size: 采样大小: + All Layers No Adjustments 所有图层(不含调整) + Current & Below No Adjustments 当前及下方图层(不含调整) + Show Sampling Ring 显示采样环 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2ff3c4c92d..ba133b8839 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2229,3 +2229,16 @@ fontWeight Italic 斜體 # Color Picker warnings Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色 Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色 + +# Eyedropper Sample Size and Sample (#1649). + Point Sample 取樣點 + 3 by 3 Average 3 × 3 平均 + 5 by 5 Average 5 × 5 平均 + 11 by 11 Average 11 × 11 平均 + 31 by 31 Average 31 × 31 平均 + 51 by 51 Average 51 × 51 平均 + 101 by 101 Average 101 × 101 平均 + Sample Size: 取樣大小: + All Layers No Adjustments 所有圖層(不含調整) + Current & Below No Adjustments 目前及以下(不含調整) + Show Sampling Ring 顯示取樣環 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 32b8535ad0..b92e73717e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -55,6 +55,7 @@ pub mod dock; pub mod enable_rules; pub mod eraser_ui; pub mod export_dialog; +pub mod eyedropper_ui; pub mod file_dialog; pub mod file_open; pub mod file_ui; @@ -313,6 +314,9 @@ pub struct Services { /// (document, compute ms, histograms)). pub(crate) type HistJob = (DocId, u64, std::sync::mpsc::Receiver<(DocId, f64, std::sync::Arc)>); +/// The Info panel's cached sample: pixel x, y, document revision and Eyedropper Sample Size. +type InfoSampleKey = (i32, i32, u64, u32); + pub struct PhotocraftApp { pub session: Session, pub ui: UiState, @@ -449,8 +453,8 @@ pub struct PhotocraftApp { /// Pointer position over the canvas (document px), for the Info panel and status bar. pub(crate) hover_doc: Option<[f64; 2]>, pub(crate) clone_preview: Option, - /// Info panel sample cache: ((x, y, revision), composite RGBA). - info_sample: Option<((i32, i32, u64), [f32; 4])>, + /// Info panel sample cache: ((x, y, revision, Sample Size), composite RGBA). + info_sample: Option<(InfoSampleKey, [f32; 4])>, /// Guide being dragged (from a ruler or with the Move tool). pub(crate) guide_drag: Option, /// Crop tool gesture in progress (see `crop_ui`). diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 02584cdec2..8af46c2d01 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -935,13 +935,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::checkbox(ui, &mut app.ui.tool_options.move_auto_select, tl!("Auto-Select:")); hint(ui, tl!("Drag to move the active layer")); } - Tool::Eyedropper => hint( - ui, - &crate::i18n::fmt( - tl!("Click to sample the foreground colour · {key}-click for background"), - &[("key", &crate::shortcuts::pretty("Alt"))], - ), - ), + Tool::Eyedropper => crate::eyedropper_ui::options(app, ui), Tool::Zoom => { widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, tl!("Scrubby Zoom")); hint( @@ -1262,15 +1256,18 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .hover_doc .map(|p| (p[0].floor() as i32, p[1].floor() as i32)) .filter(|(x, y)| *x >= 0 && *y >= 0 && *x < size.width as i32 && *y < size.height as i32); + // The readout averages the Eyedropper's Sample Size, as in Photoshop (#1649). + let sample_size = app.ui.tool_options.eyedropper_size; let rgba = pos.and_then(|(x, y)| { - if let Some(((cx, cy, cr), v)) = app.info_sample - && (cx, cy, cr) == (x, y, rev) + if let Some(((cx, cy, cr, cs), v)) = app.info_sample + && (cx, cy, cr, cs) == (x, y, rev, sample_size) { return Some(v); } - let v: Vec = serde_json::from_value(app.session.execute("document.pixel", json!({"x": x, "y": y})).ok()?).ok()?; - let v = [v[0], v[1], v[2], v[3]]; - app.info_sample = Some(((x, y, rev), v)); + let params = json!({"x": f64::from(x) + 0.5, "y": f64::from(y) + 0.5, "size": sample_size}); + let v: Vec = serde_json::from_value(app.session.execute("document.sampleColor", params).ok()?).ok()?; + let v = [*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?]; + app.info_sample = Some(((x, y, rev, sample_size), v)); Some(v) }); let mono = theme::mono(11.5); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b34217b33b..edc19c065f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -530,6 +530,18 @@ pub struct ToolOptions { pub red_eye_pupil_size: f32, #[serde(default = "fifty")] pub red_eye_darken: f32, + /// Eyedropper › Sample Size: 1 = Point Sample, else the side of the averaged square (3, 5, + /// 11, 31, 51, 101; `photocraft_engine::sample_cmds::SAMPLE_SIZES`). Shared, as in Photoshop, + /// with the ⌥-click eyedropper of the painting tools, the dialog eyedroppers and the Info panel. + #[serde(default = "one_u32")] + pub eyedropper_size: u32, + /// Eyedropper › Sample: current | currentAndBelow | all | allNoAdjustments | + /// currentAndBelowNoAdjustments (`document.sampleColor`'s `sampleLayer`). + #[serde(default = "default_eyedropper_sample")] + pub eyedropper_sample: String, + /// Eyedropper › Show Sampling Ring. + #[serde(default = "yes")] + pub eyedropper_ring: bool, } fn yes() -> bool { @@ -557,6 +569,14 @@ fn default_brush_tool() -> Tool { Tool::Brush } +fn one_u32() -> u32 { + 1 +} + +fn default_eyedropper_sample() -> String { + "all".into() +} + impl Default for ToolOptions { fn default() -> Self { Self { @@ -624,6 +644,9 @@ impl Default for ToolOptions { magnetic_pressure: false, red_eye_pupil_size: 50.0, red_eye_darken: 50.0, + eyedropper_size: 1, + eyedropper_sample: default_eyedropper_sample(), + eyedropper_ring: true, } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 86110fcf48..8e996fd9f4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -43,6 +43,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.menu.invoke {id, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. +- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. - `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` diff --git a/docs/scorecard.md b/docs/scorecard.md index 0ef0a35148..03912473ad 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | -| [Tools](#tools) | 11 | 14 | 8 | | +| [Tools](#tools) | 12 | 14 | 8 | | | [Workspace and UI](#workspace-and-ui) | 0 | 7 | 16 | settings that do nothing: 55 of 141 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) | @@ -154,6 +154,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | | TOOL-214-17 | Rotate View tool: drag to turn the view, Reset View, Match Rotation copies the angle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Tool::RotateView in the Hand flyout; view.rotateView / view.resetView; Match Rotation copies views[].rotation; enhanced_controls.rotate_view_with_trackpad is read | +| TOOL-1649-1 | Eyedropper Sample Size (Point, 3×3 … 101×101 Average), Sample layers, Show Sampling Ring | done | [#1649](https://github.com/storytold/photocraft/issues/1649) | document.sampleColor (sample_cmds.rs): average of the square clipped to the canvas, in the document's model and depth; eyedropper_ui.rs options bar; shared with Alt-click, dialog eyedroppers and the Info panel; ui.set eyedropperSampleSize/eyedropperSample/eyedropperRing. Color Sampler tool still missing | ## Workspace and UI diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 23197fe148..745040b644 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -235,3 +235,10 @@ target = "Rotate View tool: drag to turn the view, Reset View, Match Rotation co status = "done" issue = 214 note = "Tool::RotateView in the Hand flyout; view.rotateView / view.resetView; Match Rotation copies views[].rotation; enhanced_controls.rotate_view_with_trackpad is read" + +[[item]] +id = "TOOL-1649-1" +target = "Eyedropper Sample Size (Point, 3×3 … 101×101 Average), Sample layers, Show Sampling Ring" +status = "done" +issue = 1649 +note = "document.sampleColor (sample_cmds.rs): average of the square clipped to the canvas, in the document's model and depth; eyedropper_ui.rs options bar; shared with Alt-click, dialog eyedroppers and the Info panel; ui.set eyedropperSampleSize/eyedropperSample/eyedropperRing. Color Sampler tool still missing"