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:
parent
2515fa7cee
commit
65f30dd9e3
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue