feat(eyedropper): Sample Size, Sample layers and Show Sampling Ring (#1649)

The Eyedropper options bar now has Photoshop's Sample Size (Point Sample,
3 by 3 ... 101 by 101 Average), Sample (Current Layer, Current & Below,
All Layers, All Layers No Adjustments, Current & Below No Adjustments)
and Show Sampling Ring.

- engine: new `document.sampleColor {x, y, size, sampleLayer}`
  (sample_cmds.rs). Averages the square centred on the pixel, clipped to
  the canvas, alpha-weighted, in the document's colour model (Gray, Lab;
  CMYK documents use their composite RGB, since the profile round trip
  is not exact) and rounded to the document's bit depth.
- ui: eyedropper_ui.rs options bar; state.rs ToolOptions
  eyedropper_size / eyedropper_sample / eyedropper_ring (serde). The
  size is shared with the painting tools' Alt-click eyedropper, the
  Curves / Color Picker eyedroppers and the Info panel readout.
- control: ui.set eyedropperSampleSize / eyedropperSample /
  eyedropperRing; documented in control-protocol.md and the MCP schema.
- i18n: new strings in all 14 catalogs (plus the missing Russian hint
  the tl! scan had not reached).
- scorecard: TOOL-1649-1.
This commit is contained in:
Gert Pellin 2026-10-09 12:08:30 +02:00
parent 2515fa7cee
commit 65f30dd9e3
27 changed files with 968 additions and 23 deletions

View File

@ -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,
}

View File

@ -1125,6 +1125,7 @@ fn build() -> Vec<CommandSpec> {
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());

View File

@ -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;

View File

@ -0,0 +1,445 @@
//! 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> {
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<Rect> {
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<f32> = 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<LayerId>, which: SampleLayers) -> Option<std::borrow::Cow<'_, Document>> {
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<usize>, 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.
let pos = walk.iter().position(|(_, id, _)| Some(*id) == active)?;
for (path, _, _) in walk.get(pos + 1..).unwrap_or_default() {
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<LayerId>, 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<String>) -> 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<u32> {
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<SampleLayers> {
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<f64> {
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<CommandSpec> {
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<f32> = 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 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<f32> = 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<f32> =
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<f32> = 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}");
}
}
}

View File

@ -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<egui::CursorIcon> {
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::<Vec<f32>>(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`).

View File

@ -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());

View File

@ -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]);
}
}

View File

@ -2248,3 +2248,16 @@ fontWeight Italic Kurzíva
Rotate View Tool Nástroj otočení zobrazení
Reset View Obnovit zobrazení
Drag around the centre to rotate the view · Shift constrains to 15° Tažením kolem středu otočíte zobrazení · Shift omezí na 15°
# 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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2287,3 +2287,16 @@ fontWeight Italic Kursiv
Rotate View Tool Ansicht drehen-Werkzeug
Reset View Ansicht zurücksetzen
Drag around the centre to rotate the view · Shift constrains to 15° Ziehen Sie um die Mitte, um die Ansicht zu drehen · Umschalt rastet auf 15° ein
# 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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3361,3 +3361,16 @@ fontWeight Thin Πολύ λεπτά
# Saved New Document presets (#1516)
Preset Name Όνομα προεπιλογής
No saved presets yet. Δεν υπάρχουν ακόμη αποθηκευμένες προεπιλογές.
# 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 Εμφάνιση δακτυλίου δειγματοληψίας

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -2317,3 +2317,16 @@ fontWeight Italic Cursiva
Rotate View Tool Herramienta Rotar vista
Reset View Restablecer vista
Drag around the centre to rotate the view · Shift constrains to 15° Arrastre alrededor del centro para rotar la vista · Mayús fija a 15°
# 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

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2671,3 +2671,16 @@ fontWeight Italic Italique
Rotate View Tool Outil Rotation de la vue
Reset View Réinitialiser la vue
Drag around the centre to rotate the view · Shift constrains to 15° Faites glisser autour du centre pour tourner la vue · Maj contraint à 15°
# 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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2658,3 +2658,16 @@ fontWeight Italic Miring
Rotate View Tool Alat Putar Tampilan
Reset View Atur Ulang Tampilan
Drag around the centre to rotate the view · Shift constrains to 15° Seret di sekitar pusat untuk memutar tampilan · Shift membatasi ke 15°
# 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

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2318,3 +2318,16 @@ fontWeight Italic Corsivo
Rotate View Tool Strumento Ruota vista
Reset View Reimposta vista
Drag around the centre to rotate the view · Shift constrains to 15° Trascina intorno al centro per ruotare la vista · Maiusc vincola a 15°
# 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

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -2231,3 +2231,16 @@ fontWeight Italic 斜体
Rotate View Tool 表示回転ツール
Reset View 表示をリセット
Drag around the centre to rotate the view · Shift constrains to 15° 中心の周りをドラッグして表示を回転 · Shift で 15° 単位
# 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 サンプリングリングを表示

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2827,3 +2827,16 @@ fontWeight Italic 기울임
Rotate View Tool 보기 회전 도구
Reset View 보기 재설정
Drag around the centre to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기를 회전합니다 · Shift는 15°로 제한
# 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 샘플링 링 표시

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -3272,3 +3272,16 @@ cameraRaw Roughness Chropowatość
Rotate View Tool Narzędzie Obróć widok
Reset View Resetuj widok
Drag around the centre to rotate the view · Shift constrains to 15° Przeciągnij wokół środka, aby obrócić widok · Shift ogranicza do 15°
# 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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2678,3 +2678,16 @@ fontWeight Italic Itálico
Rotate View Tool Ferramenta Girar visualização
Reset View Redefinir visualização
Drag around the centre to rotate the view · Shift constrains to 15° Arraste ao redor do centro para girar a visualização · Shift restringe a 15°
# 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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2519,3 +2519,17 @@ fontWeight Italic Курсив
Rotate View Tool Инструмент поворота вида
Reset View Сбросить вид
Drag around the centre to rotate the view · Shift constrains to 15° Перетащите вокруг центра, чтобы повернуть вид · Shift фиксирует шаг 15°
# 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}-клик — фоновый цвет

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2256,3 +2256,16 @@ fontWeight Italic 斜体
Rotate View Tool 旋转视图工具
Reset View 重置视图
Drag around the centre to rotate the view · Shift constrains to 15° 绕中心拖动以旋转视图 · Shift 限制为 15°
# 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 显示采样环

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -2225,3 +2225,16 @@ fontWeight Italic 斜體
Rotate View Tool 旋轉檢視工具
Reset View 重設檢視
Drag around the centre to rotate the view · Shift constrains to 15° 繞中心拖曳以旋轉檢視 · Shift 限制為 15°
# 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 顯示取樣環

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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<tone::Histograms>)>);
/// 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<crate::canvas::ClonePreviewCache>,
/// 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<rulers::GuideDrag>,
/// Crop tool gesture in progress (see `crop_ui`).

View File

@ -872,13 +872,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(
@ -1199,15 +1193,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<f32> = 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<f32> = 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);

View File

@ -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,
}
}
}

View File

@ -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`

View File

@ -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

View File

@ -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"