Camera Raw: interactive histogram, clipping warnings and scopes (part of #215) (#407)

* Camera Raw: interactive histogram, clipping warnings and scopes (part of #215)

- RGB histogram of the preview proxy (algo::histogram), drawn as RGB ribbons;
  Before/After, five draggable tonal zones, shadow/highlight clipping warnings
  (U/O) and Alt-drag channel diagnostics.
- Pixel hover readout, up to nine RGB/Lab colour samplers (S), an ICC-managed
  vectorscope (algo::vectorscope) with skin-tone line, and a floating scope panel.
- Settings regrouped as Light, Color, Effects, Curve, Color Mixer, Color Grading
  and Detail; Camera Raw labels translated through the cameraRaw context.
- One point-curve editor (point_curve.rs) shared by Camera Raw, the Curves dialog
  and adjustment-layer Properties; fixes Camera Raw curve points not dragging.
- Double-clicking a Camera Raw smart filter reopens the Camera Raw dialog with its
  settings and updates that filter, instead of the generic parameter form.
- New point curves are validated (2-16 points, increasing inputs); stored Smart
  Filters keep loading.
- camera_raw_bench for P31/P36 (not enforced until a baseline includes them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Histogram tests: as_chunks for triangle indices (clippy 1.99)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate docs/scorecard.md after rebasing onto main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n tests: close the Mixer Brush test before the Camera Raw one (rebase)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
TiredIsa 2026-10-07 14:40:55 +03:00 committed by GitHub
parent a8afec4455
commit cc5de414dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 3665 additions and 366 deletions

1
Cargo.lock generated
View File

@ -3726,6 +3726,7 @@ dependencies = [
"egui_kittest",
"log",
"photocraft-algo",
"photocraft-cms",
"photocraft-codecs",
"photocraft-color",
"photocraft-compose",

View File

@ -29,6 +29,29 @@
use serde::{Deserialize, Serialize};
/// Most points a Camera Raw point curve accepts from commands and the control channel.
pub const MAX_CURVE_POINTS: usize = 16;
/// Checks a point curve from untrusted input: empty (linear) or 2..=16 finite points in 0..=255
/// with inputs increasing by at least one level. Deserialization stays lenient on purpose:
/// earlier editors saved curves this rejects, and [`curve_lut`] already sorts and de-duplicates
/// them, so stored Smart Filters keep rendering.
pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
if points.len() == 1 {
return Err("a nonempty curve needs at least two points".into());
}
if points.len() > MAX_CURVE_POINTS {
return Err(format!("at most {MAX_CURVE_POINTS} curve points"));
}
if !points.iter().flatten().all(|v| v.is_finite() && (0.0..=255.0).contains(v)) {
return Err("curve coordinates must be finite and in 0..=255".into());
}
if points.windows(2).any(|w| w[1][0] - w[0][0] < 1.0) {
return Err("curve inputs must increase by at least one level".into());
}
Ok(())
}
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
/// One colour grading wheel.
@ -437,6 +460,33 @@ pub fn band_weights(h: f32) -> [f32; 8] {
}
impl CameraRaw {
/// Rejects malformed point curves in new settings (see [`validate_curve`]).
pub fn validate(&self) -> Result<(), String> {
for (name, curve) in [
("pointCurve", &self.point_curve),
("pointCurveRed", &self.point_curve_red),
("pointCurveGreen", &self.point_curve_green),
("pointCurveBlue", &self.point_curve_blue),
] {
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
}
Ok(())
}
/// [`validate`](Self::validate) for an edit of `stored` settings: a curve left as stored is
/// accepted, so editing another control of an older Smart Filter still works.
pub fn validate_changes(&self, stored: &CameraRaw) -> Result<(), String> {
for (name, curve, kept) in [
("pointCurve", &self.point_curve, &stored.point_curve),
("pointCurveRed", &self.point_curve_red, &stored.point_curve_red),
("pointCurveGreen", &self.point_curve_green, &stored.point_curve_green),
("pointCurveBlue", &self.point_curve_blue, &stored.point_curve_blue),
] {
if curve != kept {
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
}
}
Ok(())
}
fn wb_neutral(&self) -> bool {
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
}
@ -869,6 +919,44 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
mod tests {
use super::*;
#[test]
fn new_curves_are_validated_but_saved_curves_still_load_and_render() {
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
for points in [
serde_json::json!([[128, 128]]),
serde_json::json!([[255, 255], [0, 0]]),
serde_json::json!([[0, 0], [0, 255]]),
serde_json::json!([[0, -1], [255, 255]]),
serde_json::json!([[0, 0], [256, 255]]),
serde_json::json!([[0, 0], [1e30, 255]]),
serde_json::json!((0..17).map(|i| [i * 15, i * 15]).collect::<Vec<_>>()),
] {
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
assert!(p.validate().is_err(), "{field}: {points}");
}
for points in
[serde_json::json!([]), serde_json::json!([[0, 0], [255, 255]]), serde_json::json!((0..16).map(|i| [i * 17, i * 17]).collect::<Vec<_>>())]
{
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
assert!(p.validate().is_ok(), "{field}: {points}");
}
}
assert_eq!(serde_json::from_str::<CameraRaw>("{}").unwrap(), CameraRaw::default());
// An older editor could stack two points on one input and exceed 16 points; such a
// saved Smart Filter must still develop instead of silently disappearing.
let mut legacy: Vec<[f32; 2]> = (0..20).map(|i| [i as f32 * 12.0, i as f32 * 12.0]).collect();
legacy.insert(5, [60.0, 200.0]);
let p: CameraRaw = serde_json::from_value(serde_json::json!({"pointCurve": legacy})).unwrap();
assert!(p.validate().is_err());
let edited = CameraRaw { exposure: 1.0, ..p.clone() };
assert!(edited.validate_changes(&p).is_ok(), "an unchanged stored curve stays editable");
let broken = CameraRaw { point_curve: vec![[10.0, 10.0]], ..p.clone() };
assert!(broken.validate_changes(&p).is_err(), "a changed curve is validated");
let mut px = img(4, 4);
develop(&mut px, 4, 4, &p, false);
assert!(px.iter().flatten().all(|v| v.is_finite()));
}
fn img(w: usize, h: usize) -> Vec<[f32; 4]> {
(0..w * h)
.map(|i| {

View File

@ -0,0 +1,176 @@
//! Bounded RGB histograms of straight, normalized float RGBA samples.
//!
//! No colour conversion is performed: bins describe the supplied sample domain, not an
//! assumed sRGB/ICC display space. 256 bins are a display resolution, not an input bit depth.
pub const BINS: usize = 256;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RgbHistogram {
pub channels: [[u64; BINS]; 3],
pub samples: u64,
pub transparent: u64,
pub invalid: u64,
/// Finite samples outside 0..=1 are included in the edge bins and counted separately.
pub underflow: [u64; 3],
pub overflow: [u64; 3],
/// Exact endpoints, before display-bin rounding. A near-white value is not clipped.
pub shadows: [u64; 3],
pub highlights: [u64; 3],
}
impl Default for RgbHistogram {
fn default() -> Self {
Self { channels: [[0; BINS]; 3], samples: 0, transparent: 0, invalid: 0, underflow: [0; 3], overflow: [0; 3], shadows: [0; 3], highlights: [0; 3] }
}
}
impl RgbHistogram {
/// Each visible pixel counts once, including partially transparent pixels. Non-finite
/// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks.
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
let mut h = Self::default();
for pixel in pixels {
if !pixel[3].is_finite() {
h.invalid = h.invalid.saturating_add(1);
continue;
}
if pixel[3] <= 0.0 {
h.transparent = h.transparent.saturating_add(1);
continue;
}
if !pixel.iter().all(|v| v.is_finite()) {
h.invalid = h.invalid.saturating_add(1);
continue;
}
h.samples = h.samples.saturating_add(1);
let (low, high) = clipping(*pixel);
for (c, ((((bins, below), above), shadows), highlights)) in
h.channels.iter_mut().zip(&mut h.underflow).zip(&mut h.overflow).zip(&mut h.shadows).zip(&mut h.highlights).enumerate()
{
let Some(value) = pixel.get(c) else { continue };
if low & (1 << c) != 0 {
*shadows = shadows.saturating_add(1);
}
if high & (1 << c) != 0 {
*highlights = highlights.saturating_add(1);
}
if *value < 0.0 {
*below = below.saturating_add(1);
} else if *value > 1.0 {
*above = above.saturating_add(1);
}
// Match the preview encoder's nearest-bin rule, before it loses float precision.
let bin = (value.clamp(0.0, 1.0) * (BINS - 1) as f32 + 0.5) as usize;
if let Some(count) = bins.get_mut(bin) {
*count = count.saturating_add(1);
}
}
}
h
}
}
/// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B):
/// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins.
/// Transparent and non-finite pixels clip nothing.
pub fn clipping(p: [f32; 4]) -> (u8, u8) {
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
return (0, 0);
}
let mut shadows = 0;
let mut highlights = 0;
for (c, v) in p.iter().take(3).enumerate() {
if *v <= 0.0 {
shadows |= 1 << c;
}
if *v >= 1.0 {
highlights |= 1 << c;
}
}
(shadows, highlights)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_bins_and_channel_totals() {
let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect();
let h = RgbHistogram::from_rgba(&pixels);
assert_eq!(h.samples, 256);
assert!(h.channels[0].iter().all(|&v| v == 1));
assert!(h.channels[1].iter().all(|&v| v == 1));
assert_eq!(h.channels[2][128], 256);
for channel in h.channels {
assert_eq!(channel.iter().sum::<u64>(), h.samples);
}
}
#[test]
fn empty_transparent_invalid_and_hdr_are_explicit() {
assert_eq!(RgbHistogram::from_rgba(&[]), RgbHistogram::default());
let pixels = [[0.7, 0.9, 0.4, 0.0], [f32::NAN, 0.0, 0.0, 1.0], [0.0, f32::INFINITY, 0.0, 1.0], [0.0, 0.0, 0.0, f32::NAN], [-2.0, 4.0, 0.5, 1.0]];
let original = pixels;
let h = RgbHistogram::from_rgba(&pixels);
assert_eq!((h.samples, h.transparent, h.invalid), (1, 1, 3));
assert_eq!(h.underflow, [1, 0, 0]);
assert_eq!(h.overflow, [0, 1, 0]);
assert_eq!((h.channels[0][0], h.channels[1][255], h.channels[2][128]), (1, 1, 1));
assert_eq!(pixels[4], original[4]);
assert!(pixels[1][0].is_nan());
}
#[test]
fn clipping_indicators_use_exact_samples_not_edge_bin_rounding() {
let h = RgbHistogram::from_rgba(&[[0.0001, 0.9999, 0.5, 1.0], [-0.2, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0]]);
assert_eq!(h.shadows, [1, 0, 1]);
assert_eq!(h.highlights, [0, 1, 0]);
assert_eq!(h.channels[0][0], 2);
assert_eq!(h.channels[1][255], 2);
}
#[test]
fn rgb_and_gray_at_all_depths_use_the_same_float_contract() {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for sample in SampleType::ALL {
let mut surface = Surface::new(PixelFormat::new(mode, sample, true));
for (x, v) in [0.0, 0.25, 0.75, 1.0].into_iter().enumerate() {
let px = if mode == ColorMode::Rgb { vec![v, v, v, 1.0] } else { vec![v, 1.0] };
surface.write_pixel(x as i32, 0, &px);
}
let mut pixels = [[0.0; 4]; 4];
surface.read_rgba_into(Rect::new(0, 0, 4, 1), &mut pixels);
let h = RgbHistogram::from_rgba(&pixels);
assert_eq!(h.samples, 4, "{mode:?}/{sample:?}");
for c in &h.channels {
for bin in [0, 64, 191, 255] {
assert_eq!(c[bin], 1, "{mode:?}/{sample:?}, bin {bin}");
}
}
}
}
}
#[test]
fn exact_clipping_endpoints_not_quantized_neighbours() {
assert_eq!(clipping([0.0001, 0.9999, 0.5, 1.0]), (0, 0));
assert_eq!(clipping([-0.1, 1.0, 0.0, 1.0]), (5, 2));
assert_eq!(clipping([0.0, 1.0, 0.0, 0.0]), (0, 0));
assert_eq!(clipping([0.0, f32::INFINITY, 0.0, 1.0]), (0, 0));
}
#[test]
fn exposure_of_an_eight_bit_gradient_reproduces_the_quantization_comb() {
let mut pixels: Vec<_> = (0..256).map(|i| [i as f32 / 255.0, i as f32 / 255.0, i as f32 / 255.0, 1.0]).collect();
assert!(RgbHistogram::from_rgba(&pixels).channels[0].iter().all(|&v| v == 1));
crate::camera_raw::develop(&mut pixels, 256, 1, &crate::camera_raw::CameraRaw { exposure: 0.5, ..Default::default() }, true);
let h = RgbHistogram::from_rgba(&pixels);
assert!(h.channels[0][10..230].iter().filter(|&&v| v == 0).count() > 10);
assert_eq!(h.samples, 256);
assert!(h.overflow[0] > 0);
}
}

View File

@ -33,6 +33,7 @@ pub mod features;
mod fxutil;
mod gallery;
pub mod hdr;
pub mod histogram;
mod image;
pub mod inpaint;
pub mod lens;
@ -68,6 +69,7 @@ pub mod tone;
pub mod transform;
pub mod trap;
pub mod vanishing;
pub mod vectorscope;
mod video;
pub mod warp;
pub mod wideangle;

View File

@ -0,0 +1,72 @@
//! Bounded hue/saturation distribution. Input colours must already be in the chosen RGB space.
//! Display orientation and colour management belong to callers, not to this analysis.
pub const HUES: usize = 128;
pub const SATURATIONS: usize = 64;
#[derive(Clone, Debug, PartialEq)]
pub struct Vectorscope {
pub counts: Vec<u32>,
pub samples: u64,
}
impl Default for Vectorscope {
fn default() -> Self {
Self { counts: vec![0; HUES * SATURATIONS], samples: 0 }
}
}
impl Vectorscope {
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
let mut out = Self::default();
for p in pixels {
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
continue;
}
let rgb = [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0), p[2].clamp(0.0, 1.0)];
let (h, s) = hue_saturation(rgb);
let hue = ((h * HUES as f32) as usize).min(HUES - 1);
let saturation = ((s * (SATURATIONS - 1) as f32).round() as usize).min(SATURATIONS - 1);
if let Some(n) = out.counts.get_mut(hue * SATURATIONS + saturation) {
*n = n.saturating_add(1);
}
out.samples = out.samples.saturating_add(1);
}
out
}
}
/// Hue as turns and saturation in 0..1; achromatic pixels have zero saturation.
pub fn hue_saturation([r, g, b]: [f32; 3]) -> (f32, f32) {
let high = r.max(g).max(b);
let low = r.min(g).min(b);
let delta = high - low;
if delta <= f32::EPSILON || high <= 0.0 {
return (0.0, 0.0);
}
let h = if high == r {
(g - b) / delta
} else if high == g {
2.0 + (b - r) / delta
} else {
4.0 + (r - g) / delta
};
((h / 6.0).rem_euclid(1.0), delta / high)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_primary_positions_neutrals_and_transparency() {
for (rgb, expected) in [([1.0, 0.0, 0.0], 0.0), ([1.0, 1.0, 0.0], 1.0 / 6.0), ([0.0, 1.0, 0.0], 1.0 / 3.0), ([0.0, 0.0, 1.0], 2.0 / 3.0)] {
let (h, s) = hue_saturation(rgb);
assert!((h - expected).abs() < 1e-6);
assert_eq!(s, 1.0);
}
let v = Vectorscope::from_rgba(&[[0.5, 0.5, 0.5, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 1.0]]);
assert_eq!(v.samples, 2);
assert_eq!(v.counts[0], 1);
assert_eq!(v.counts[SATURATIONS - 1], 1);
assert_eq!(v.counts.iter().map(|&c| u64::from(c)).sum::<u64>(), v.samples);
}
}

View File

@ -346,6 +346,8 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result<Value> {
fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cr = raw_params(RAW, p)?;
// New settings are strict; stored Smart Filters re-apply through the lenient `raw_params`.
cr.validate().map_err(|e| bad(RAW, e))?;
let t0 = Stopwatch::start();
let id = run_filter(s, RAW, "Camera Raw Filter", p.clone(), false, &|surf, canvas| camera_raw_surface(surf, canvas.union(&surf.content_bounds()), &cr))?;
Ok(json!({"layer": id.0, "identity": cr.is_identity(), "ms": t0.ms()}))
@ -529,6 +531,12 @@ mod tests {
assert!(active_px(&s, 10, 40)[1] < inside_before[1]);
assert_eq!(active_px(&s, 100, 40), outside_before);
assert!(s.execute(RAW, json!({"exposure": "bright"})).is_err());
let legacy_curve = json!([[0, 0], [60, 40], [60, 200], [255, 255]]);
assert!(s.execute(RAW, json!({"pointCurve": legacy_curve})).is_err(), "new curves are validated");
// A curve an older editor saved must still re-apply as a Smart Filter.
let surf = s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
let stored = apply_to_surface(RAW, &json!({"pointCurve": legacy_curve, "exposure": 1.0}), &surf, Rect::new(0, 0, 120, 80));
assert!(stored.is_some(), "a stored legacy curve must not drop the whole filter");
// Smart filter.
let mut s = session(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();

View File

@ -304,6 +304,16 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result<Option<Surface>> {
Ok(Some(apply_smart_filters(&placed, sm, doc.bounds())))
}
/// The smart object's pixels below smart filter `index` (the input that filter edits): the placed
/// source and the filters under it, without the filter mask. `Ok(None)` when the source is
/// unavailable.
pub fn render_below_filter(doc: &Document, sm: &SmartObject, index: usize) -> Result<Option<Surface>> {
let mut below = sm.clone();
below.smart_filters.truncate(index);
below.filter_mask = None;
render(doc, &below)
}
/// Re-renders smart layer `l` (which lives in `doc`) in place. Returns false if its source is
/// unavailable (the cache is then left alone).
pub fn refresh_layer(doc: &Document, l: &mut Layer) -> Result<bool> {

View File

@ -10,6 +10,7 @@ workspace = true
[dependencies]
photocraft-geom = { workspace = true }
photocraft-color = { workspace = true }
photocraft-cms = { workspace = true }
photocraft-raster = { workspace = true }
photocraft-doc = { workspace = true }
photocraft-compose = { workspace = true }

View File

@ -0,0 +1,122 @@
//! Real Camera Raw proxy-update timing on a 36 MP document (CPU preparation, not GPU presentation).
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui};
use serde_json::{Value, json};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let quick = args.iter().any(|a| a == "--quick");
let (width, height, reps) = if quick { (6000, 4000, 12) } else { (7360, 4912, 40) };
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, Default::default());
let _ = ctx.run_ui(Default::default(), |_| {});
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
// A full-resolution synthetic image with dense hue/saturation coverage and exact clipped
// endpoints. Setup/import is outside the interactive measurements.
let mut bytes = Vec::with_capacity(width * height * 3);
for y in 0..height {
for x in 0..width {
bytes.extend([(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8]);
}
}
let image =
photocraft_codecs::Image::from_raw(width as u32, height as u32, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, bytes)?;
let encoded = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default())?;
app.session.open_document(photocraft_io::import("scope.png", &encoded)?.document, None);
camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).ok_or("no dialog entry point")??;
let mut samples = Vec::new();
let mut histogram_samples = Vec::new();
for i in 0..reps + 3 {
let r = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": if i % 2 == 0 { 0.25 } else { 0.75 }}}}))
.ok_or("no dialog entry point")??;
if i >= 3 {
samples.push(r.get("renderMs").and_then(Value::as_f64).ok_or("no render timing")?);
if let Some(ms) = r.get("histogramMs").and_then(Value::as_f64) {
histogram_samples.push(ms);
}
}
// Drain texture deltas just as a real frame does, rather than retaining every upload.
let _ = ctx.run_ui(Default::default(), |_| {});
}
// Measure the complete native dialog CPU path with every SDR scope feature enabled:
// filter preparation + ICC vectorscope + clipping texture + nine Lab probes + pixel-hover overlays + UI mesh.
// A full-coverage selection exercises the mask blend and selected-region scope without
// reducing the amount of analysed colour data. Selection setup is outside the timer.
app.run("select.rect", json!({"x":0,"y":0,"width":width,"height":height}))?;
camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).ok_or("no dialog entry point")??;
camera_raw_ui::menu(
&mut app,
&ctx,
"filter.cameraRaw",
&json!({"ui":{"scope":{
"vectorscope":true,"selectedRegion":true,"lab":true,"shadows":true,"highlights":true,
"samplers":(0..9).map(|i| [i as f32/9.0,0.5]).collect::<Vec<_>>()
}}}),
)
.ok_or("no dialog entry point")??;
let mut interactive_samples = Vec::new();
let mut scope_samples = Vec::new();
let mut idle_samples = Vec::new();
let input = || egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))),
events: vec![egui::Event::PointerMoved(egui::pos2(430.0, 400.0))],
..Default::default()
};
for i in 0..reps + 3 {
let start = std::time::Instant::now();
let r = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui":{"set":{"exposure":if i%2==0 {0.25}else{0.75}},"scope":{}}}))
.ok_or("no dialog entry point")??;
let output = ctx.run_ui(input(), |ui| camera_raw_ui::show(&mut app, ui.ctx()));
std::hint::black_box(ctx.tessellate(output.shapes, output.pixels_per_point));
if i >= 3 {
interactive_samples.push(start.elapsed().as_secs_f64() * 1000.0);
scope_samples.push(r.get("scopeMs").and_then(Value::as_f64).ok_or("no scope timing")?);
}
let start = std::time::Instant::now();
let output = ctx.run_ui(input(), |ui| camera_raw_ui::show(&mut app, ui.ctx()));
std::hint::black_box(ctx.tessellate(output.shapes, output.pixels_per_point));
if i >= 3 {
idle_samples.push(start.elapsed().as_secs_f64() * 1000.0);
}
if i == 3 {
let state = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui":{}})).ok_or("no dialog entry point")??;
if state["hoverSample"]["hueSaturation"].is_null() {
return Err("benchmark must render the RGB badge and vectorscope pixel target".into());
}
}
}
// Dense RGB curves exercise the shared widget, including its outlines and egui CPU
// tessellation. This is measured separately from pixel processing and GPU presentation.
let pixels: Vec<_> = (0..8192)
.map(|i| {
let x = (i % 128) as f32;
let y = (i / 128) as f32;
[0.5 + 0.4 * (x * 0.07).sin(), 0.5 + 0.4 * (y * 0.13).cos(), (x + y) / 192.0, 1.0]
})
.collect();
let histogram = photocraft_algo::histogram::RgbHistogram::from_rgba(&pixels);
let mut draw_samples = Vec::new();
for i in 0..reps + 3 {
let start = std::time::Instant::now();
let output = ctx.run_ui(
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(330.0, 160.0))), ..Default::default() },
|ui| {
photocraft_ui_egui::widgets::rgb_histogram(ui, &histogram, 110.0);
},
);
let _meshes = ctx.tessellate(output.shapes, output.pixels_per_point);
if i >= 3 {
draw_samples.push(start.elapsed().as_secs_f64() * 1000.0);
}
}
let mut sorted = samples.clone();
sorted.sort_by(f64::total_cmp);
let p50 = sorted.get(sorted.len() / 2).copied().unwrap_or(0.0);
let p95 = sorted.get(sorted.len().saturating_sub(1) * 95 / 100).copied().unwrap_or(0.0);
let max = sorted.last().copied().unwrap_or(0.0);
let report = json!({"width": width, "height": height, "rows": [{"name": "exposure proxy update", "median_ms": p50, "p95_ms": p95, "max_ms": max, "samples_ms": samples}, {"name": "RGB histogram", "samples_ms": histogram_samples}, {"name": "RGB histogram paint + tessellation", "samples_ms": draw_samples}, {"name":"interactive scope update", "samples_ms":interactive_samples}, {"name":"vectorscope ICC analysis", "samples_ms":scope_samples}, {"name":"cached scope frame", "samples_ms":idle_samples}]});
println!("{}", serde_json::to_string_pretty(&report)?);
if let Some(path) = args.iter().position(|a| a == "--json").and_then(|i| args.get(i + 1)) {
std::fs::write(path, serde_json::to_vec_pretty(&report)?)?;
}
Ok(())
}

View File

@ -168,4 +168,41 @@ mod tests {
assert!(std::sync::Arc::ptr_eq(&before, &after));
assert!(harness.state().ui.dialogs.is_empty());
}
#[test]
fn curves_pointer_drag_previews_until_confirm_and_cancel_preserves_document() {
for confirm in [false, true] {
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
let original = h.state().session.active().unwrap().doc.clone();
let steps = h.state().session.active().unwrap().history.past_len();
let id = open(h.state_mut(), "image.adjustments.curves").unwrap();
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!([[0, 0], [128, 128], [255, 255]]));
h.run_steps(4);
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::new(("adjust-dialog", "curves")).with("curves-graph"))).unwrap();
let a = egui::pos2(graph.left() + 128.0 / 255.0 * graph.width(), graph.bottom() - 128.0 / 255.0 * graph.height());
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
h.hover_at(a + egui::vec2(40.0, -32.0));
h.run_steps(1);
h.drop_at(a + egui::vec2(40.0, -32.0));
h.run_steps(3);
let points = &h.state().ui.dialogs[0].fields["points"];
assert_eq!(points.as_array().unwrap().len(), 3);
assert!(points[1][0].as_f64().unwrap() > 150.0 && points[1][1].as_f64().unwrap() > 150.0, "{points}");
assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc));
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps);
if confirm {
crate::dialogs::confirm(h.state_mut(), id).unwrap();
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc));
} else {
h.state_mut().ui.close_dialog(id);
h.run_steps(2);
assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc));
}
}
}
}

View File

@ -6,9 +6,11 @@
//! and `commit` when a gesture ends (the Properties host then runs one `layer.setAdjustment`; a
//! dialog commits on OK). Selective Color and Color Lookup keep their editors in `adjust_ui`.
use crate::point_curve as curve_edit;
use crate::state::CurvesEditorState as CurveUi;
use std::sync::Arc;
use egui::{Color32, Key, Modifiers, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
use egui::{Color32, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
use photocraft_doc::adjust::ToneSpace;
use photocraft_doc::{Adjustment, LayerId};
use photocraft_engine::adjust_params::{self, HUE_RANGES, PHOTO_FILTERS};
@ -301,75 +303,6 @@ fn gradient_bar(p: &egui::Painter, r: Rect, from: Color32, to: Color32, vertical
// ---------------------------------------------------------------------------------------------
// Curves
/// Curve point editing as pure functions on `[input, output]` points in 0..=255, sorted by input.
pub mod curve_edit {
use egui::Pos2;
/// Most points a curve can hold (Photoshop's limit).
pub const MAX_POINTS: usize = 16;
/// Points closer than this (input levels) are the same point.
pub const MIN_GAP: f32 = 2.0;
/// Index of the point nearest `pos` (screen) within `radius` pixels.
pub fn hit(pts: &[[f32; 2]], to_scr: impl Fn([f32; 2]) -> Pos2, pos: Pos2, radius: f32) -> Option<usize> {
pts.iter().enumerate().map(|(i, q)| (i, to_scr(*q).distance(pos))).filter(|(_, d)| *d <= radius).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
}
/// Adds a point at `v` (kept sorted). A point already at that input is selected instead; a
/// full curve takes no more points.
pub fn insert(pts: &mut Vec<[f32; 2]>, v: [f32; 2]) -> Option<usize> {
let v = [v[0].clamp(0.0, 255.0), v[1].clamp(0.0, 255.0)];
if let Some(i) = pts.iter().position(|q| (q[0] - v[0]).abs() < MIN_GAP) {
return Some(i);
}
if pts.len() >= MAX_POINTS {
return None;
}
let i = pts.iter().position(|q| q[0] > v[0]).unwrap_or(pts.len());
pts.insert(i, v);
Some(i)
}
/// Endpoints stay, and a curve keeps at least two points.
pub fn can_delete(pts: &[[f32; 2]], i: usize) -> bool {
pts.len() > 2 && i > 0 && i + 1 < pts.len()
}
pub fn delete(pts: &mut Vec<[f32; 2]>, i: usize) -> bool {
if !can_delete(pts, i) {
return false;
}
pts.remove(i);
true
}
/// Moves point `i` to `v`, kept strictly between its neighbours' inputs.
pub fn move_to(pts: &mut [[f32; 2]], i: usize, v: [f32; 2]) -> bool {
let n = pts.len();
if i >= n {
return false;
}
let lo = if i > 0 { pts[i - 1][0] + 1.0 } else { 0.0 };
let hi = if i + 1 < n { pts[i + 1][0] - 1.0 } else { 255.0 };
let new = [v[0].round().clamp(lo, hi.max(lo)), v[1].round().clamp(0.0, 255.0)];
let changed = new != pts[i];
pts[i] = new;
changed
}
}
#[derive(Clone, Copy, Debug, Default)]
struct CurveUi {
channel: usize,
sel: Option<usize>,
/// The point being dragged (None while dragged off the graph), the grab offset and whether
/// the drag removed it.
drag: Option<(Option<usize>, [f32; 2], bool)>,
}
/// Dragging a point this far outside the graph removes it (it comes back if dragged back in).
pub const DRAG_OFF: f32 = 12.0;
const HIT_RADIUS: f32 = 9.0;
const CURVE_HANDLE_SIZE: f32 = 7.0;
const CURVE_GRAPH_PADDING: f32 = 5.0;
@ -417,91 +350,10 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
let graph = curve_graph(full, side);
ui.data_mut(|d| d.insert_temp(cx.mem.with("curves-graph"), graph));
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
let to_val =
|s: Pos2| [((s.x - graph.left()) / graph.width() * 255.0).clamp(0.0, 255.0), ((graph.bottom() - s.y) / graph.height() * 255.0).clamp(0.0, 255.0)];
let mut e = Edit::default();
let mut changed = false;
// Press: hit-test where the button went down (not where a drag is first recognised).
let press = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed());
if press && let Some(pos) = ui.input(|i| i.pointer.press_origin()) {
resp.request_focus();
let delete = ui.input(|i| i.modifiers.command);
match curve_edit::hit(&pts, to_scr, pos, HIT_RADIUS) {
Some(i) if delete => {
if curve_edit::delete(&mut pts, i) {
changed = true;
e.commit = true;
}
st.sel = None;
st.drag = None;
}
Some(i) => {
st.sel = Some(i);
let g = to_val(pos);
st.drag = Some((Some(i), [pts[i][0] - g[0], pts[i][1] - g[1]], false));
}
None if graph.contains(pos) && !delete => {
let before = pts.len();
st.sel = curve_edit::insert(&mut pts, to_val(pos));
changed = pts.len() != before;
st.drag = st.sel.map(|i| (Some(i), [0.0, 0.0], false));
}
None => st.drag = None,
}
}
// Drag: follow the pointer; off the graph the point is removed (back on, it returns).
if resp.is_pointer_button_down_on()
&& !press
&& let (Some((index, off, removed)), Some(pos)) = (st.drag, ui.input(|i| i.pointer.interact_pos()))
{
let outside = !graph.expand(DRAG_OFF).contains(pos);
let g = to_val(pos);
let target = [g[0] + off[0], g[1] + off[1]];
match index {
Some(i) if outside && curve_edit::can_delete(&pts, i) => {
pts.remove(i);
st.drag = Some((None, off, true));
st.sel = None;
changed = true;
}
Some(i) => changed |= curve_edit::move_to(&mut pts, i, target),
None if removed && !outside => {
st.sel = curve_edit::insert(&mut pts, target);
st.drag = Some((st.sel, off, true));
changed |= st.sel.is_some();
}
None => {}
}
}
if st.drag.is_some() && !resp.is_pointer_button_down_on() {
st.drag = None;
e.commit = true;
}
// Keys while the graph has focus: Delete/Backspace removes, arrows nudge (Shift: ×10).
if resp.has_focus()
&& let Some(i) = st.sel.filter(|i| *i < pts.len())
{
let del = ui.input_mut(|inp| inp.consume_key(Modifiers::NONE, Key::Delete) || inp.consume_key(Modifiers::NONE, Key::Backspace));
if del && curve_edit::delete(&mut pts, i) {
st.sel = None;
changed = true;
e.commit = true;
} else {
let step = if ui.input(|inp| inp.modifiers.shift) { 10.0 } else { 1.0 };
let mut d = [0.0f32; 2];
for (k, dx, dy) in [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, 1.0), (Key::ArrowDown, 0.0, -1.0)] {
if ui.input_mut(|inp| inp.consume_key(Modifiers::NONE, k) || inp.consume_key(Modifiers::SHIFT, k)) {
d = [d[0] + dx * step, d[1] + dy * step];
}
}
let to = [pts[i][0] + d[0], pts[i][1] + d[1]];
if d != [0.0, 0.0] && curve_edit::move_to(&mut pts, i, to) {
changed = true;
e.commit = true;
}
}
}
let interaction = crate::point_curve::interact(ui, &resp, graph, &mut pts, &mut st.gesture);
let mut changed = interaction.changed;
e.commit = interaction.commit;
// Draw: histogram, quarter grid, baseline, gradient bars, other channels, the curve, points.
let p = ui.painter_at(full.expand(2.0));
@ -547,7 +399,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
draw_curve(&pts, if ch == 0 { t.text } else { bar_color(&chan, &t) }, 1.5);
for (i, q) in pts.iter().enumerate() {
let r = Rect::from_center_size(to_scr(*q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE));
if Some(i) == st.sel {
if Some(i) == st.gesture.selected {
p.rect_filled(r, 0.0, t.text);
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
} else {
@ -559,7 +411,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
// Input / Output of the selected point.
ui.add_space(6.0);
ui.horizontal(|ui| {
let i = st.sel.filter(|i| *i < pts.len());
let i = st.gesture.selected.filter(|i| *i < pts.len());
let (mut vi, mut vo) = i.map_or((0.0, 0.0), |i| (pts[i][0], pts[i][1]));
label(ui, tl!("Input:"));
let ri = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vi, 0.0..=255.0, "", 52.0)).inner;
@ -1371,57 +1223,8 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj
#[cfg(test)]
mod tests {
use super::curve_edit::*;
use super::*;
fn line() -> Vec<[f32; 2]> {
vec![[0.0, 0.0], [255.0, 255.0]]
}
#[test]
fn insert_keeps_order_dedupes_and_respects_the_limit() {
let mut p = line();
assert_eq!(insert(&mut p, [128.0, 150.0]), Some(1));
assert_eq!(insert(&mut p, [64.0, 40.0]), Some(1));
assert_eq!(p, vec![[0.0, 0.0], [64.0, 40.0], [128.0, 150.0], [255.0, 255.0]]);
// A click on an existing input selects that point instead of stacking a second one.
assert_eq!(insert(&mut p, [129.0, 10.0]), Some(2));
assert_eq!(p.len(), 4);
let mut full: Vec<[f32; 2]> = (0..MAX_POINTS).map(|i| [i as f32 * 10.0, 0.0]).collect();
assert_eq!(insert(&mut full, [255.0, 0.0]), None);
assert_eq!(insert(&mut p, [-50.0, 900.0]), Some(0), "clamped onto the black point");
}
#[test]
fn endpoints_and_the_last_two_points_stay() {
let mut p = vec![[0.0, 0.0], [100.0, 120.0], [255.0, 255.0]];
assert!(!delete(&mut p, 0) && !delete(&mut p, 2) && !delete(&mut p, 7));
assert!(delete(&mut p, 1));
assert_eq!(p, line());
assert!(!delete(&mut p, 1) && !delete(&mut p, 0));
assert_eq!(p.len(), 2);
}
#[test]
fn moves_stay_between_neighbours() {
let mut p = vec![[0.0, 0.0], [100.0, 120.0], [200.0, 180.0], [255.0, 255.0]];
assert!(move_to(&mut p, 1, [250.0, 300.0]));
assert_eq!(p[1], [199.0, 255.0]);
move_to(&mut p, 0, [150.0, 10.0]);
assert_eq!(p[0], [150.0, 10.0]);
move_to(&mut p, 3, [10.0, 5.0]);
assert_eq!(p[3], [201.0, 5.0]);
assert!(!move_to(&mut p, 9, [0.0, 0.0]));
}
#[test]
fn hit_test_picks_the_nearest_point_in_radius() {
let p = vec![[0.0, 0.0], [100.0, 100.0], [110.0, 110.0], [255.0, 255.0]];
let to_scr = |q: [f32; 2]| Pos2::new(q[0], 255.0 - q[1]);
assert_eq!(hit(&p, to_scr, Pos2::new(108.0, 146.0), 9.0), Some(2));
assert_eq!(hit(&p, to_scr, Pos2::new(50.0, 50.0), 9.0), None);
}
#[test]
fn curve_endpoint_handles_fit_inside_the_allocated_area() {
for side in [120.0, 200.0, 300.0] {

View File

@ -209,3 +209,29 @@ fn curves_points_are_added_selected_and_deleted() {
h.run_steps(3);
assert_eq!(curve_of(&layer(&h).1).len(), 2, "endpoints can't be deleted");
}
/// Capture and history are shared with Camera Raw, but Properties commits on release.
#[test]
fn curves_fast_release_commits_one_edit_and_undo_restores_the_curve() {
let mut h = app_harness("curves", "rgb", 8);
let (id, _) = layer(&h);
let graph = curves_graph(&h, id);
click(&mut h, graph.center());
let (_, before) = layer(&h);
let steps = h.state().session.active().unwrap().history.past_len();
let at = graph.center() + vec2(4.0, 3.0);
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
// Only the release frame supplies the destination, as a very fast physical gesture can.
h.drop_at(at + vec2(40.0, -32.0));
h.run_steps(3);
let after = curve_of(&layer(&h).1);
assert_eq!(after.len(), 3);
assert!(after[1][0] > 150.0 && after[1][1] > 150.0, "{after:?}");
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
assert!(h.state().live_adjust.is_none());
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(layer(&h).1, before);
}

View File

@ -0,0 +1,685 @@
//! Camera Raw scope interaction. View state is in UiState; filter edits still commit through
//! the existing filter.cameraRaw command. Image analysis is cached per completed proxy revision.
use crate::{camera_raw_ui::CameraRawDialog, state::CameraRawScopeState, theme::Tokens, widgets};
use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, TextureHandle, pos2, vec2};
use photocraft_algo::{
camera_raw::CameraRaw,
histogram::clipping,
vectorscope::{HUES, SATURATIONS, Vectorscope},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Warning combinations and Alt diagnostics are separate modes: both warnings must not
/// accidentally select the shadow-channel diagnostic (the old numeric value was shared).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ClippingMode {
None,
Warnings(u8),
ShadowChannels,
HighlightChannels,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum ToneZone {
Blacks,
Shadows,
Exposure,
Highlights,
Whites,
}
impl ToneZone {
fn at(x: f32) -> Self {
if x < 0.1 {
Self::Blacks
} else if x < 0.25 {
Self::Shadows
} else if x < 0.75 {
Self::Exposure
} else if x < 0.9 {
Self::Highlights
} else {
Self::Whites
}
}
/// English source label, shared with the Light slider.
pub(crate) fn name(self) -> &'static str {
match self {
Self::Blacks => "Blacks",
Self::Shadows => "Shadows",
Self::Exposure => "Exposure",
Self::Highlights => "Highlights",
Self::Whites => "Whites",
}
}
fn label(self) -> &'static str {
crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", self.name())
}
fn range(self) -> [f32; 2] {
match self {
Self::Blacks => [0.0, 0.1],
Self::Shadows => [0.1, 0.25],
Self::Exposure => [0.25, 0.75],
Self::Highlights => [0.75, 0.9],
Self::Whites => [0.9, 1.0],
}
}
fn value(self, p: &mut CameraRaw) -> &mut f32 {
match self {
Self::Blacks => &mut p.blacks,
Self::Shadows => &mut p.shadows,
Self::Exposure => &mut p.exposure,
Self::Highlights => &mut p.highlights,
Self::Whites => &mut p.whites,
}
}
pub(crate) fn limit(self) -> f32 {
if self == Self::Exposure { 5.0 } else { 100.0 }
}
fn set(self, d: &mut CameraRawDialog, value: f32) {
let value = value.clamp(-self.limit(), self.limit());
let target = self.value(&mut d.params);
if *target != value {
*target = value;
d.dirty = true;
}
}
}
/// Most colour samplers (probes) on the preview.
pub(crate) const MAX_SAMPLERS: usize = 9;
/// Vectorscope analysis samples at most this many proxy pixels.
const SCOPE_SAMPLES: usize = 8192;
/// Scope caches and gesture state of one open dialog; never filter parameters or history.
#[derive(Default)]
pub(crate) struct ScopeView {
/// Vectorscope keyed by (preview revision, Before, selected region).
pub(crate) cache: Option<(u64, bool, bool, Vectorscope)>,
pub(crate) ms: f64,
pub(crate) revision: u64,
pub(crate) overlay_tex: Option<TextureHandle>,
pub(crate) overlay_key: Option<(u64, bool, ClippingMode, crate::theme::ThemeKind)>,
pub(crate) floating_dragged: bool,
pub(crate) geometry_requested: bool,
pub(crate) sampler_drag: Option<usize>,
pub(crate) pointer_sample: Option<[f32; 2]>,
/// Histogram drag: zone, pointer start x and the value at the start.
pub(crate) tone_drag: Option<(ToneZone, f32, f32)>,
pub(crate) hovered_zone: Option<ToneZone>,
/// Tone slider being Alt-dragged in the settings panel. The panel draws after the preview,
/// so the preview's diagnostics follow it one frame later.
pub(crate) alt_tone: Option<ToneZone>,
pub(crate) preview_rect: Option<Rect>,
pub(crate) rect: Option<Rect>,
pub(crate) vectorscope_rect: Option<Rect>,
}
/// One visible proxy pixel, in the histogram RGB domain and ICC-managed vectorscope domain.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HoverSample {
pub(crate) position: [f32; 2],
pub(crate) rgb: [f32; 3],
pub(crate) hue_saturation: Option<[f32; 2]>,
}
/// Floating panel bounds `[left, top, right, bottom]` in points: finite and 200×150..=4096².
fn valid_floating_rect(r: [f32; 4]) -> bool {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
r.iter().all(|x| x.is_finite()) && (200.0..=4096.0).contains(&w) && (150.0..=4096.0).contains(&h)
}
impl CameraRawDialog {
fn pixels(&self) -> &[[f32; 4]] {
if self.show_before { &self.proxy } else { &self.processed }
}
fn pixel(&self, p: [f32; 2]) -> Option<[f32; 4]> {
if !p.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) || self.pw == 0 || self.ph == 0 {
return None;
}
let x = ((p[0] * self.pw as f32) as usize).min(self.pw - 1);
let y = ((p[1] * self.ph as f32) as usize).min(self.ph - 1);
let q = *self.pixels().get(y.checked_mul(self.pw)?.checked_add(x)?)?;
(q[3] > 0.0 && q.iter().all(|v| v.is_finite())).then_some(q)
}
pub(crate) fn readout(&self, p: [f32; 2], lab: bool) -> Option<Value> {
let q = self.pixel(p)?;
let rgb = [q[0], q[1], q[2]];
let values = if lab {
let mut l = [0.0; 3];
self.lab_transform.eval(&rgb, &mut l);
[l[0] * 100.0, l[1] * 255.0 - 128.0, l[2] * 255.0 - 128.0]
} else {
rgb.map(|v| v * 255.0)
};
Some(json!({"position":p,"space":if lab {"lab"}else{"rgb"},"values":values,"alpha":q[3]}))
}
pub(crate) fn hover_sample(&self, vectorscope: bool) -> Option<HoverSample> {
let position = self.scope.pointer_sample?;
let q = self.pixel(position)?;
let rgb = [q[0], q[1], q[2]];
let hue_saturation = if vectorscope {
let mut scope_rgb = [0.0; 3];
self.scope_transform.eval(&rgb, &mut scope_rgb);
if scope_rgb.iter().all(|x| x.is_finite()) {
let (h, s) = photocraft_algo::vectorscope::hue_saturation(scope_rgb.map(|x| x.clamp(0.0, 1.0)));
Some([h, s])
} else {
None
}
} else {
None
};
Some(HoverSample { position, rgb, hue_saturation })
}
pub(crate) fn ensure_scope(&mut self, selected: bool) {
if self
.scope
.cache
.as_ref()
.is_some_and(|(revision, before, masked, _)| *revision == self.preview_revision && *before == self.show_before && *masked == selected)
{
return;
}
let start = crate::gpu_canvas::now_ms();
// A fixed 2-D grid bounds CMS and analysis work independently of the image size. Rows
// and columns step separately, so a stride can't alias with the row width into stripes.
let step = ((self.pixels().len() as f64 / SCOPE_SAMPLES as f64).sqrt().ceil() as usize).max(1);
let (pw, ph) = (self.pw, self.ph);
let pixels: Vec<_> = (0..ph)
.step_by(step)
.flat_map(|y| (0..pw).step_by(step).map(move |x| y * pw + x))
.filter_map(|i| self.pixels().get(i).map(|p| (i, p)))
.filter(|(i, p)| {
p[3] > 0.0 && p.iter().all(|v| v.is_finite()) && (!selected || self.selection.as_ref().and_then(|m| m.get(*i)).is_some_and(|v| *v > 0.0))
})
.map(|(_, p)| {
let mut rgb = [0.0; 3];
self.scope_transform.eval(&p[..3], &mut rgb);
[rgb[0], rgb[1], rgb[2], p[3]]
})
.collect();
let scope = Vectorscope::from_rgba(&pixels);
self.scope.cache = Some((self.preview_revision, self.show_before, selected, scope));
self.scope.ms = crate::gpu_canvas::now_ms() - start;
self.scope.revision = self.scope.revision.saturating_add(1);
}
}
/// Remember dialog presentation through the existing preferences store, after a gesture ends.
/// Probe positions are image-specific and never restored onto a different document.
pub(crate) fn persist(app: &mut crate::PhotocraftApp, ctx: &egui::Context) {
if ctx.input(|i| i.pointer.any_down()) {
return;
}
let mut view = app.ui.camera_raw_scope.clone();
view.samplers.clear();
view.vectorscope = false;
let Ok(value) = serde_json::to_value(view) else {
return;
};
const KEY: &str = "filter.cameraRaw.scope";
if app.session.prefs().dialogs.get(KEY) != Some(&value) {
app.session.prefs.edit(|p| p.dialogs.insert(KEY.into(), value));
}
}
fn point(v: &Value) -> Result<[f32; 2], String> {
let p: [f32; 2] = serde_json::from_value(v.clone()).map_err(|e| e.to_string())?;
if !p.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x)) {
return Err("sample coordinates must be normalized to 0..1".into());
}
Ok(p)
}
/// The same controller operations drive real gestures and automation. Validate the entire request
/// before changing state; invalid coordinates, huge vectors, and unknown properties are errors.
pub(crate) struct ScopeUpdate {
view: CameraRawScopeState,
sample: Option<Option<[f32; 2]>>,
tone: Option<(ToneZone, f32)>,
geometry_requested: bool,
}
impl ScopeUpdate {
pub(crate) fn apply(self, d: &mut CameraRawDialog, view: &mut CameraRawScopeState) {
d.scope.geometry_requested |= self.geometry_requested;
*view = self.view;
if let Some(s) = self.sample {
d.scope.pointer_sample = s;
}
if let Some((z, v)) = self.tone {
z.set(d, v);
}
}
}
pub(crate) fn prepare_control(params: &CameraRaw, has_selection: bool, view: &CameraRawScopeState, value: &Value) -> Result<ScopeUpdate, String> {
let obj = value.as_object().ok_or("scope must be an object")?;
if obj.get("samplers").is_some_and(|v| v.as_array().is_none_or(|a| a.len() > MAX_SAMPLERS)) {
return Err(format!("at most {MAX_SAMPLERS} samplers"));
}
if obj.contains_key("samplers") && ["addSampler", "removeSampler", "clearSamplers"].iter().any(|k| obj.contains_key(*k)) {
return Err("samplers replaces the list; don't combine it with addSampler, removeSampler or clearSamplers".into());
}
// Plain view fields first, then the sampler operations on the result (clear → remove → add).
let mut base = serde_json::to_value(view).map_err(|e| e.to_string())?;
let mut sample = None;
let mut tone = None;
for (key, v) in obj {
match key.as_str() {
"sample" => sample = Some(if v.is_null() { None } else { Some(point(v)?) }),
"addSampler" | "removeSampler" | "clearSamplers" => {}
"tone" => {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Change {
zone: ToneZone,
delta: Option<f32>,
reset: Option<bool>,
}
let c: Change = serde_json::from_value(v.clone()).map_err(|e| e.to_string())?;
if c.delta.is_some_and(|x| !x.is_finite()) || (c.delta.is_none() && c.reset != Some(true)) {
return Err("tone requires finite delta or reset=true".into());
}
let mut params = params.clone();
tone = Some((c.zone, if c.reset == Some(true) { 0.0 } else { *c.zone.value(&mut params) + c.delta.unwrap_or(0.0) }));
}
_ => {
if base.get(key).is_none() {
return Err(format!("unknown scope property {key}"));
}
base[key] = v.clone();
}
}
}
let mut next: CameraRawScopeState = serde_json::from_value(base).map_err(|e| e.to_string())?;
if let Some(v) = obj.get("clearSamplers") {
if v.as_bool() != Some(true) {
return Err("clearSamplers must be true".into());
}
next.samplers.clear();
}
if let Some(v) = obj.get("removeSampler") {
let i = v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or("bad sampler index")?;
if i >= next.samplers.len() {
return Err("unknown sampler".into());
}
next.samplers.remove(i);
}
if let Some(v) = obj.get("addSampler") {
if next.samplers.len() >= MAX_SAMPLERS {
return Err(format!("at most {MAX_SAMPLERS} samplers"));
}
next.samplers.push(point(v)?);
}
for p in &next.samplers {
point(&json!(p))?;
}
if next.floating_rect.is_some_and(|r| !valid_floating_rect(r)) {
return Err("invalid floating scope rectangle".into());
}
if next.selected_region && !has_selection {
return Err("no selected region in this document".into());
}
Ok(ScopeUpdate { view: next, sample, tone, geometry_requested: obj.contains_key("floatingRect") })
}
fn readout_text(value: Option<Value>) -> String {
let Some(v) = value else { return "—".into() };
let a = v.get("values").and_then(Value::as_array);
let get = |i| a.and_then(|a| a.get(i)).and_then(Value::as_f64).unwrap_or(0.0);
if v.get("space").and_then(Value::as_str) == Some("lab") {
format!("L {:.1} a {:.1} b {:.1}", get(0), get(1), get(2))
} else {
format!("R {:.0} G {:.0} B {:.0}", get(0), get(1), get(2))
}
}
fn warning(ui: &mut egui::Ui, rect: Rect, mask: u8, on: &mut bool, label: &str) {
let t = Tokens::get(ui.ctx());
let response = ui.interact(rect, ui.id().with(label), Sense::click()).on_hover_text(label);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, label));
let r = rect.shrink(3.0);
ui.painter().add(egui::Shape::convex_polygon(
vec![pos2(r.center().x, r.top()), r.left_bottom(), r.right_bottom()],
if mask == 0 { t.histogram_background() } else { t.histogram_color(mask) },
Stroke::new(1.0, if *on { t.text } else { t.text_faint }),
));
if response.clicked() {
*on = !*on;
}
}
/// Scope display options. The menu stays open while toggling, so options that depend on
/// Show Vectorscope appear in place.
fn context_menu(response: &egui::Response, has_selection: bool, view: &mut CameraRawScopeState) {
egui::Popup::context_menu(response).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| context(ui, has_selection, view));
}
fn context(ui: &mut egui::Ui, has_selection: bool, view: &mut CameraRawScopeState) {
widgets::checkbox(ui, &mut view.lab, tl!("Show Lab Color Readouts"));
widgets::checkbox(ui, &mut view.vectorscope, tl!("Show Vectorscope"));
if view.vectorscope {
if has_selection {
widgets::checkbox(ui, &mut view.selected_region, tl!("Show Selected Region"));
}
widgets::checkbox(ui, &mut view.red_right, tl!("Show Red at 3 o'clock"));
let mut skin = !view.hide_skin_line;
widgets::checkbox(ui, &mut skin, tl!("Show Skin Tone Indicator"));
view.hide_skin_line = !skin;
}
}
fn graph(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, height: f32) {
let t = Tokens::get(ui.ctx());
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width().max(1.0), height), Sense::click_and_drag());
d.scope.rect = Some(rect);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), tl!("Tone Histogram")));
ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background());
let plot = rect.shrink(4.0);
crate::rgb_histogram::paint(ui.painter(), plot, d.histogram(), &t);
let corner = 18.0;
if let Some(sample) = d.hover_sample(false) {
crate::rgb_histogram::paint_sample(ui.painter(), plot, sample.rgb, &t);
let [r, g, b] = sample.rgb.map(|x| x * 255.0);
let text = format!("R {r:.0} G {g:.0} B {b:.0}");
let font = crate::theme::mono(11.0);
let galley = ui.painter().layout_no_wrap(text, font, t.text);
let width = (galley.size().x + 12.0).min((rect.width() - 2.0 * corner - 4.0).max(1.0));
let badge = Rect::from_center_size(pos2(rect.center().x, rect.top() + 13.0), vec2(width, 20.0));
ui.painter().rect_filled(badge, t.radius_sm, t.card.gamma_multiply(0.65));
ui.painter().rect_stroke(badge, t.radius_sm, Stroke::new(0.7, t.field_border.gamma_multiply(0.55)), egui::StrokeKind::Inside);
ui.painter().with_clip_rect(badge.shrink(2.0)).galley(badge.center() - galley.size() * 0.5, galley, t.text);
}
let sh = d.histogram().shadows.iter().enumerate().fold(0u8, |m, (c, n)| m | if *n > 0 { 1 << c } else { 0 });
let hi = d.histogram().highlights.iter().enumerate().fold(0u8, |m, (c, n)| m | if *n > 0 { 1 << c } else { 0 });
warning(ui, Rect::from_min_size(rect.min, vec2(corner, corner)), sh, &mut view.shadows, tl!("Shadow Clipping Warning (U)"));
warning(
ui,
Rect::from_min_size(pos2(rect.right() - corner, rect.top()), vec2(corner, corner)),
hi,
&mut view.highlights,
tl!("Highlight Clipping Warning (O)"),
);
let pos = response.interact_pointer_pos().or_else(|| response.hover_pos());
d.scope.hovered_zone = pos.filter(|p| p.y > rect.top() + corner).map(|p| ToneZone::at(((p.x - plot.left()) / plot.width().max(1.0)).clamp(0.0, 1.0)));
if let Some(z) = d.scope.tone_drag.map(|x| x.0).or(d.scope.hovered_zone) {
let [lo, hi] = z.range();
let region = Rect::from_min_max(pos2(plot.left() + lo * plot.width(), plot.top()), pos2(plot.left() + hi * plot.width(), plot.bottom()));
ui.painter().rect_filled(region, 0.0, t.text.gamma_multiply(0.055));
response.clone().on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
response.clone().on_hover_text(format!("{} {:.2}", z.label(), *z.value(&mut d.params)));
if response.double_clicked() {
z.set(d, 0.0);
}
if response.drag_started_by(egui::PointerButton::Primary)
&& let Some(p) = pos
{
let delta = response.drag_delta().x;
d.scope.tone_drag = Some((z, p.x - delta, *z.value(&mut d.params)));
}
}
if response.dragged_by(egui::PointerButton::Primary)
&& let (Some(p), Some((z, start, initial))) = (pos, d.scope.tone_drag)
{
z.set(d, initial + (p.x - start) / plot.width().max(1.0) * z.limit() * 2.0);
}
if response.drag_stopped() {
d.scope.tone_drag = None;
}
context_menu(&response, d.selection.is_some(), view);
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
}
/// The cloud and pixel target use exactly the same orientation and saturation radius.
fn scope_point(rect: Rect, red_right: bool, hue: f32, saturation: f32) -> Pos2 {
let radius = (rect.width().min(rect.height()) * 0.5 - 14.0).max(1.0);
let offset = if red_right { 0.0 } else { -100.0f32.to_radians() };
let angle = offset - hue * std::f32::consts::TAU;
rect.center() + vec2(angle.cos(), angle.sin()) * saturation * radius
}
fn vectorscope(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, height: f32) {
d.ensure_scope(view.selected_region);
let t = Tokens::get(ui.ctx());
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
context_menu(&response, d.selection.is_some(), view);
ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background());
d.scope.vectorscope_rect = Some(rect);
let center = rect.center();
let radius = (rect.width().min(rect.height()) * 0.5 - 14.0).max(1.0);
let point = |h: f32, s: f32| scope_point(rect, view.red_right, h, s);
for ring in [0.25, 0.5, 0.75, 1.0] {
ui.painter().circle_stroke(center, radius * ring, Stroke::new(0.7, t.text_faint.gamma_multiply(0.45)));
}
let mut mesh = egui::Mesh::default();
if let Some((_, _, _, data)) = &d.scope.cache {
let peak = data.counts.iter().copied().max().unwrap_or(1).max(1) as f32;
for (i, count) in data.counts.iter().enumerate().filter(|(_, c)| **c > 0) {
let h = (i / SATURATIONS) as f32 / HUES as f32;
let s = (i % SATURATIONS) as f32 / (SATURATIONS - 1) as f32;
let p = point(h, s);
let color = t.scope_hue(h, s).gamma_multiply(0.2 + 0.8 * (*count as f32 / peak).sqrt());
let dot = (radius / (SATURATIONS - 1) as f32).clamp(0.7, 3.0);
mesh.add_colored_rect(Rect::from_center_size(p, vec2(dot * 2.0, dot * 2.0)), color);
}
}
ui.painter().add(mesh);
for (i, label) in ["R", "Y", "G", "C", "B", "M"].iter().enumerate() {
let h = i as f32 / 6.0;
ui.painter().line_segment([center, point(h, 1.0)], Stroke::new(0.6, t.text_faint.gamma_multiply(0.4)));
ui.painter().circle_filled(point(h, 1.0), 2.0, t.scope_hue(h, 1.0));
ui.painter().text(point(h, 1.11), Align2::CENTER_CENTER, label, FontId::proportional(10.0), t.scope_hue(h, 1.0));
}
if !view.hide_skin_line {
ui.painter().line_segment([center, point(23.0 / 360.0, 1.0)], Stroke::new(1.0, t.text_dim));
}
if let Some(sample) = d.hover_sample(true)
&& let Some([h, saturation]) = sample.hue_saturation
{
let at = point(h, saturation);
let painter = ui.painter().with_clip_rect(rect);
for stroke in [Stroke::new(3.0, t.histogram_background()), Stroke::new(1.2, t.histogram_color(7))] {
painter.circle_stroke(at, 5.0, stroke);
painter.line_segment([at - vec2(9.0, 0.0), at + vec2(9.0, 0.0)], stroke);
painter.line_segment([at - vec2(0.0, 9.0), at + vec2(0.0, 9.0)], stroke);
}
}
}
pub(crate) fn header(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState) {
view.samplers.truncate(MAX_SAMPLERS);
view.samplers.retain(|p| p.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x)));
if view.floating_rect.is_some_and(|r| !valid_floating_rect(r)) {
view.floating_rect = None;
}
if view.floating {
let (_, response) = ui.allocate_exact_size(vec2(ui.available_width(), 48.0), Sense::click());
context_menu(&response, d.selection.is_some(), view);
} else {
let height = (ui.available_height() * 0.18).clamp(48.0, 110.0);
graph(ui, d, view, height);
if view.vectorscope {
vectorscope(ui, d, view, (ui.available_height() * 0.3).clamp(100.0, 220.0));
}
}
ui.add_space(8.0);
}
pub(crate) fn floating(ctx: &egui::Context, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, dock: Rect) {
if !view.floating {
return;
}
let r = view.floating_rect.unwrap_or([40.0, 60.0, 400.0, 440.0]);
let content = ctx.content_rect();
let start = pos2(
r[0].clamp(content.left(), (content.right() - 200.0).max(content.left())),
r[1].clamp(content.top(), (content.bottom() - 150.0).max(content.top())),
);
let window = egui::Window::new("")
.id(egui::Id::new("cr-floating-scope"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(true)
.default_pos(start)
.default_size(vec2(r[2] - r[0], r[3] - r[1]))
.min_size(vec2(200.0, 150.0));
let window = if d.scope.geometry_requested {
d.scope.geometry_requested = false;
let size = vec2(r[2] - r[0], r[3] - r[1]).min(content.size().max(vec2(1.0, 1.0)));
// egui's title-bar drag restores the previous pivot in this frame. Disable moving
// only for the explicit controller geometry update, then restore normal dragging.
window.movable(false).current_pos(start).min_size(size).max_size(size)
} else {
window
};
let response = window.show(ctx, |ui| {
let height = if view.vectorscope { (ui.available_height() * 0.35).clamp(48.0, 150.0) } else { (ui.available_height() - 28.0).max(48.0) };
graph(ui, d, view, height);
if view.vectorscope {
vectorscope(ui, d, view, (ui.available_height() - 12.0).max(100.0));
}
});
if let Some(response) = response {
let previous = pos2(r[0], r[1]);
let r = response.response.rect;
view.floating_rect = Some([r.left(), r.top(), r.right(), r.bottom()]);
if ctx.input(|i| i.pointer.primary_down()) && r.min.distance(previous) > 0.5 {
d.scope.floating_dragged = true;
}
if ctx.input(|i| i.pointer.any_released()) {
if d.scope.floating_dragged && ctx.input(|i| i.pointer.interact_pos().is_some_and(|p| dock.contains(p))) {
view.floating = false;
}
d.scope.floating_dragged = false;
}
}
}
pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, rect: Rect) {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, ui.id().with("cr-sample-preview"), Sense::click_and_drag());
if let Some(p) = resp.hover_pos() {
d.scope.pointer_sample =
Some([((p.x - rect.left()) / rect.width().max(1.0)).clamp(0.0, 1.0), ((p.y - rect.top()) / rect.height().max(1.0)).clamp(0.0, 1.0)]);
} else {
d.scope.pointer_sample = None;
}
let to_screen = |p: [f32; 2]| pos2(rect.left() + p[0] * rect.width(), rect.top() + p[1] * rect.height());
if view.sampler_tool {
resp.clone().on_hover_cursor(egui::CursorIcon::Crosshair);
if resp.clicked()
&& let Some(p) = d.scope.pointer_sample
{
let near = view.samplers.iter().position(|q| to_screen(*q).distance(to_screen(p)) < 12.0);
if ui.input(|i| i.modifiers.alt) {
if let Some(i) = near {
view.samplers.remove(i);
}
} else if near.is_none() && view.samplers.len() < MAX_SAMPLERS {
view.samplers.push(p);
}
}
if resp.drag_started() {
let start = resp.interact_pointer_pos().map(|p| p - resp.drag_delta());
d.scope.sampler_drag = view.samplers.iter().position(|q| start.is_some_and(|s| to_screen(*q).distance(s) < 16.0));
}
if resp.dragged()
&& let (Some(p), Some(i)) = (d.scope.pointer_sample, d.scope.sampler_drag)
&& let Some(q) = view.samplers.get_mut(i)
{
*q = p;
}
if resp.drag_stopped() {
d.scope.sampler_drag = None;
}
}
let alt_zone = d.scope.tone_drag.map(|d| d.0).or(d.scope.alt_tone);
let mode = if ui.input(|i| i.modifiers.alt && i.pointer.primary_down()) && alt_zone.is_some() {
if matches!(alt_zone, Some(ToneZone::Blacks | ToneZone::Shadows)) { ClippingMode::ShadowChannels } else { ClippingMode::HighlightChannels }
} else if view.shadows || view.highlights {
ClippingMode::Warnings(u8::from(view.shadows) | (u8::from(view.highlights) << 1))
} else {
ClippingMode::None
};
let key = (d.preview_revision, d.show_before, mode, t.kind);
if mode != ClippingMode::None {
if d.scope.overlay_key != Some(key) {
let colors = d
.pixels()
.iter()
.map(|p| {
let (lo, hi) = clipping(*p);
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
return Color32::TRANSPARENT;
}
match mode {
ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo),
ClippingMode::HighlightChannels => {
if hi == 0 {
t.histogram_background()
} else {
t.histogram_color(hi)
}
}
ClippingMode::Warnings(warnings) => {
if warnings & 2 != 0 && hi != 0 {
t.histogram_color(1)
} else if warnings & 1 != 0 && lo != 0 {
t.histogram_color(4)
} else {
Color32::TRANSPARENT
}
}
ClippingMode::None => Color32::TRANSPARENT,
}
})
.collect();
let image = egui::ColorImage::new([d.pw, d.ph], colors);
match &mut d.scope.overlay_tex {
Some(t) => t.set(image, egui::TextureOptions::NEAREST),
None => d.scope.overlay_tex = Some(ui.ctx().load_texture("cr-clipping", image, egui::TextureOptions::NEAREST)),
}
d.scope.overlay_key = Some(key);
}
if let Some(tex) = &d.scope.overlay_tex {
ui.painter().image(tex.id(), rect, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
}
}
if !view.samplers.is_empty() {
ui.painter().rect_filled(
Rect::from_min_size(rect.min, vec2(rect.width().min(330.0), view.samplers.len().min(MAX_SAMPLERS) as f32 * 16.0 + 12.0)),
t.radius_sm,
t.dock.gamma_multiply(0.92),
);
}
for (i, p) in view.samplers.iter().take(MAX_SAMPLERS).enumerate() {
let q = to_screen(*p);
ui.painter().circle_stroke(q, 5.0, Stroke::new(1.0, t.text));
ui.painter().line_segment([q - vec2(8.0, 0.0), q + vec2(8.0, 0.0)], Stroke::new(1.0, t.text));
ui.painter().line_segment([q - vec2(0.0, 8.0), q + vec2(0.0, 8.0)], Stroke::new(1.0, t.text));
let text = format!("{} {}", i + 1, readout_text(d.readout(*p, view.lab)));
ui.painter().text(pos2(rect.left() + 6.0, rect.top() + 6.0 + i as f32 * 16.0), Align2::LEFT_TOP, text, crate::theme::mono(11.0), t.text);
ui.painter().text(q + vec2(7.0, 7.0), Align2::LEFT_TOP, format!("{}", i + 1), crate::theme::mono(11.0), t.text);
}
}
pub(crate) fn shortcuts(ctx: &egui::Context, view: &mut CameraRawScopeState) {
if ctx.text_edit_focused() {
return;
}
ctx.input_mut(|i| {
if i.consume_key(egui::Modifiers::NONE, egui::Key::U) {
view.shadows = !view.shadows;
}
if i.consume_key(egui::Modifiers::NONE, egui::Key::O) {
view.highlights = !view.highlights;
}
if i.consume_key(egui::Modifiers::NONE, egui::Key::S) {
view.sampler_tool = !view.sampler_tool;
}
});
}

View File

@ -1,5 +1,5 @@
//! Filter › Camera Raw Filter…: a large dialog like Adobe Camera Raw's (preview on the left,
//! edit panels on the right: Basic, Curve, Detail, Color Mixer, Color Grading, Effects).
//! edit panels on the right: Light, Color, Effects, Curve, Color Mixer, Color Grading, Detail).
//!
//! The preview runs [`photocraft_algo::camera_raw::develop`] on a CPU proxy of the layer
//! (≤ 900 px, `pixel_scale` keeps pixel radii true to the full image), recomputed when a
@ -11,6 +11,9 @@
use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2};
use photocraft_algo::camera_raw::{CameraRaw, Wheel, curve_lut};
use photocraft_algo::histogram::RgbHistogram;
use crate::camera_raw_scope_ui::{MAX_SAMPLERS, ToneZone};
use photocraft_doc::LayerId;
use photocraft_geom::Rect;
use serde_json::{Value, json};
@ -27,23 +30,71 @@ pub struct CameraRawDialog {
pub layer: LayerId,
layer_name: String,
pub params: CameraRaw,
/// Settings when the dialog opened (stored settings of an edited smart filter).
original: CameraRaw,
target: Target,
curve_state: crate::state::PointCurveState,
curve_rect: Option<ERect>,
/// Proxy pixels (straight RGBA) and size.
proxy: Vec<[f32; 4]>,
pw: usize,
ph: usize,
pub(crate) proxy: Vec<[f32; 4]>,
pub(crate) selection: Option<Vec<f32>>,
pub(crate) pw: usize,
pub(crate) ph: usize,
full_w: usize,
float: bool,
pub(crate) float: bool,
tex: Option<TextureHandle>,
before_tex: Option<TextureHandle>,
dirty: bool,
pub(crate) before_histogram: RgbHistogram,
pub(crate) processed: Vec<[f32; 4]>,
pub(crate) lab_transform: std::sync::Arc<photocraft_cms::Transform>,
pub(crate) scope_transform: std::sync::Arc<photocraft_cms::Transform>,
pub(crate) scope: super::camera_raw_scope_ui::ScopeView,
pub(crate) after_histogram: RgbHistogram,
pub(crate) preview_revision: u64,
histogram_ms: f64,
pub(crate) dirty: bool,
pub show_before: bool,
mixer_tab: usize,
pub render_ms: f64,
}
impl CameraRawDialog {
pub fn describe(&self) -> Value {
json!({"layer": self.layer.0, "params": serde_json::to_value(&self.params).unwrap_or(Value::Null), "proxy": [self.pw, self.ph], "renderMs": self.render_ms, "before": self.show_before})
pub fn describe(&self, view: &crate::state::CameraRawScopeState) -> Value {
let h = self.histogram();
let mut result = json!({"layer": self.layer.0, "params": serde_json::to_value(&self.params).unwrap_or(Value::Null), "proxy": [self.pw, self.ph],
"renderMs": self.render_ms, "histogramMs": self.histogram_ms, "previewRevision": self.preview_revision, "before": self.show_before,
"histogram": {"source": if self.show_before { "before" } else { "after" }, "approximate": true, "size": [self.pw, self.ph],
"sampleDomain": "preview-rgb", "range": [0, 1], "red": h.channels[0].as_slice(), "green": h.channels[1].as_slice(), "blue": h.channels[2].as_slice(),
"samples": h.samples, "transparent": h.transparent, "invalid": h.invalid, "underflow": h.underflow, "overflow": h.overflow, "shadows": h.shadows, "highlights": h.highlights}});
result["smartFilter"] = json!(match self.target {
Target::SmartFilter(index) => Some(index),
Target::NewFilter => None,
});
result["scope"] = serde_json::to_value(view).unwrap_or(Value::Null);
result["hasScopeSelection"] = json!(self.selection.is_some());
result["vectorscope"] = json!(
self.scope
.cache
.as_ref()
.map(|(_, before, selected, data)| json!({"samples":data.samples,"before":before,"selectedRegion":selected,"bins":data.counts}))
);
result["scopeMs"] = json!(self.scope.ms);
result["scopeRevision"] = json!(self.scope.revision);
result["overlayRevision"] = json!(self.scope.overlay_key.map(|k| k.0));
result["pointerReadout"] = self.scope.pointer_sample.and_then(|p| self.readout(p, view.lab)).unwrap_or(Value::Null);
result["samplerReadouts"] = json!(view.samplers.iter().take(MAX_SAMPLERS).map(|p| self.readout(*p, view.lab)).collect::<Vec<_>>());
result["hoveredZone"] = json!(self.scope.hovered_zone);
result["previewRect"] = json!(self.scope.preview_rect.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
result["curveState"] = json!(self.curve_state);
result["curveRect"] = json!(self.curve_rect.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
result["hoverSample"] = json!(self.hover_sample(view.vectorscope));
result["vectorscopeRect"] = json!(self.scope.vectorscope_rect.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
result["scopeRect"] = json!(self.scope.rect.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
result
}
pub(crate) fn histogram(&self) -> &RgbHistogram {
if self.show_before { &self.before_histogram } else { &self.after_histogram }
}
/// Params for the engine: only the settings that differ from the defaults.
@ -66,12 +117,22 @@ impl CameraRawDialog {
egui::ColorImage::new([w, h], px.iter().map(|q| Color32::from_rgba_unmultiplied(enc(q[0]), enc(q[1]), enc(q[2]), enc(q[3]))).collect())
}
fn render(&mut self, ctx: &egui::Context) {
pub(crate) fn render(&mut self, ctx: &egui::Context) {
let t0 = crate::gpu_canvas::now_ms();
let mut px = self.proxy.clone();
let mut p = self.params.clone();
p.pixel_scale = (self.pw as f32 / self.full_w.max(1) as f32).min(1.0);
photocraft_algo::camera_raw::develop(&mut px, self.pw, self.ph, &p, self.float);
if let Some(mask) = &self.selection {
for ((out, original), coverage) in px.iter_mut().zip(&self.proxy).zip(mask) {
for (out, original) in out.iter_mut().zip(original) {
*out = *original + (*out - *original) * coverage;
}
}
}
let histogram_start = crate::gpu_canvas::now_ms();
self.after_histogram = RgbHistogram::from_rgba(&px);
self.histogram_ms = crate::gpu_canvas::now_ms() - histogram_start;
let img = Self::image(&px, self.pw, self.ph);
match &mut self.tex {
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
@ -80,15 +141,66 @@ impl CameraRawDialog {
if self.before_tex.is_none() {
self.before_tex = Some(ctx.load_texture("camera-raw-before", Self::image(&self.proxy, self.pw, self.ph), egui::TextureOptions::LINEAR));
}
self.processed = px;
self.scope.cache = None;
self.render_ms = crate::gpu_canvas::now_ms() - t0;
self.preview_revision = self.preview_revision.saturating_add(1);
self.dirty = false;
}
}
/// Opens the dialog on the active layer.
/// Where OK writes: a new filter on the layer, or an existing smart filter (by index).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Target {
NewFilter,
SmartFilter(usize),
}
/// What limits the preview: the document selection (a new filter applies through it, as the
/// engine does), a smart object's filter mask, or nothing.
enum Coverage {
Selection,
Mask(photocraft_doc::LayerMask),
None,
}
pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
photocraft_engine::commands::find("filter.cameraRaw").map(|c| (c.enabled)(&app.session)).unwrap_or(Err("unknown command".into()))?;
let (layer, surf, _) = crate::distort_ui::active_pixels(app)?;
open_pixels(app, ctx, layer, surf, CameraRaw::default(), Coverage::Selection, Target::NewFilter)
}
/// Opens Camera Raw on smart filter `index` of smart layer `layer`, with its stored settings and
/// the pixels below it; OK updates that filter (`layer.smartFilter.setParams`), as double-clicking
/// a Camera Raw smart filter does in Photoshop.
pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: LayerId, index: usize) -> Result<(), String> {
use photocraft_engine::lens_cmds::{RAW, raw_params};
let st = app.session.active().ok_or("no document")?;
let Some(photocraft_doc::LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else {
return Err("the layer is not a smart object".into());
};
let filter = sm.smart_filters.get(index).ok_or("no such smart filter")?;
if filter.command != RAW {
return Err("the smart filter is not a Camera Raw filter".into());
}
let params = raw_params(RAW, &filter.params).map_err(|e| e.to_string())?;
let surf = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index)
.map_err(|e| e.to_string())?
.ok_or("the smart object's contents are unavailable (missing linked file?)")?;
let coverage = sm.filter_mask.clone().filter(|m| m.enabled).map_or(Coverage::None, Coverage::Mask);
open_pixels(app, ctx, layer, surf, params, coverage, Target::SmartFilter(index))
}
fn open_pixels(
app: &mut PhotocraftApp,
ctx: &egui::Context,
layer: LayerId,
surf: photocraft_raster::Surface,
params: CameraRaw,
coverage: Coverage,
target: Target,
) -> Result<(), String> {
let st = app.session.active().ok_or("no document")?;
let canvas = st.doc.bounds();
let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default();
@ -117,12 +229,50 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
proxy[py * pw + x] = [a[0] / a[4], a[1] / a[4], a[2] / a[4], a[3] / a[4]];
}
}
// Coverage at the centre of each proxy block.
let sample = |value: &dyn Fn(i32, i32) -> f32| {
(0..ph)
.flat_map(|y| (0..pw).map(move |x| (area.x0 + (x * k + k / 2).min(w - 1) as i32, area.y0 + (y * k + k / 2).min(h - 1) as i32)))
.map(|(x, y)| value(x, y).clamp(0.0, 1.0))
.collect::<Vec<_>>()
};
let selection = match &coverage {
Coverage::Selection => st.doc.selection.as_ref().map(|mask| sample(&|x, y| mask.sample_channel(x, y, 0))),
Coverage::Mask(mask) => Some(sample(&|x, y| mask.value(x, y))),
Coverage::None => None,
};
let source = photocraft_engine::color_cmds::composite_profile(&st.doc);
let lab_transform = photocraft_cms::cached(&source, photocraft_cms::Builtin::LabD50.profile(), Default::default()).map_err(|e| e.to_string())?;
let scope_transform = photocraft_cms::cached(&source, photocraft_cms::Builtin::Srgb.profile(), Default::default()).map_err(|e| e.to_string())?;
if let Some(saved) = app.session.prefs().dialogs.get("filter.cameraRaw.scope")
&& saved.get("samplers").and_then(Value::as_array).is_none_or(|a| a.len() <= MAX_SAMPLERS)
&& let Ok(view) = serde_json::from_value(saved.clone())
{
app.ui.camera_raw_scope = view;
}
app.ui.camera_raw_scope.samplers.clear();
// Keep Camera Raw compact on open; vectorscope is an opt-in context-menu view.
app.ui.camera_raw_scope.vectorscope = false;
if selection.is_none() {
app.ui.camera_raw_scope.selected_region = false;
}
let float = surf.format().sample == photocraft_color::SampleType::F32;
let mut d = CameraRawDialog {
layer,
layer_name: name,
params: CameraRaw::default(),
original: params.clone(),
params,
target,
before_histogram: RgbHistogram::from_rgba(&proxy),
processed: Vec::new(),
lab_transform,
scope_transform,
scope: Default::default(),
after_histogram: RgbHistogram::default(),
preview_revision: 0,
histogram_ms: 0.0,
proxy,
selection,
pw,
ph,
full_w: w,
@ -132,6 +282,8 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
dirty: true,
show_before: false,
mixer_tab: 1,
curve_state: Default::default(),
curve_rect: None,
render_ms: 0.0,
};
d.render(ctx);
@ -144,37 +296,106 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
if id != "filter.cameraRaw" {
return None;
}
let empty = params.as_object().is_none_or(|o| o.is_empty());
if empty {
return Some(open(app, ctx).map(|_| app.camera_raw.as_ref().map(|d| d.describe()).unwrap_or(Value::Null)));
let Some(fields) = params.as_object() else {
return Some(Err("Camera Raw params must be an object".into()));
};
if fields.is_empty() {
return Some(open(app, ctx).map(|_| app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null)));
}
if let Some(sf) = params.get("smartFilter") {
let opened = (|| {
if fields.len() > 1 {
return Err("smartFilter opens the dialog; send ui requests separately".to_string());
}
let layer = sf.get("layer").and_then(Value::as_u64).ok_or("smartFilter.layer must be a layer id")?;
let index = sf.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or("smartFilter.index must be a filter index")?;
open_smart_filter(app, ctx, LayerId(layer), index)?;
Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null))
})();
return Some(opened);
}
let ui = params.get("ui")?;
if app.camera_raw.is_none()
&& let Err(e) = open(app, ctx)
{
let Some(fields) = ui.as_object() else {
return Some(Err("Camera Raw ui must be an object".into()));
};
for (key, value) in fields {
match key.as_str() {
"set" | "scope" => {}
"before" | "commit" | "cancel" if value.is_boolean() => {}
"before" | "commit" | "cancel" => return Some(Err(format!("Camera Raw {key} must be a boolean"))),
_ => return Some(Err(format!("unknown Camera Raw ui property {key}"))),
}
}
if ui.get("commit").and_then(Value::as_bool) == Some(true) && ui.get("cancel").and_then(Value::as_bool) == Some(true) {
return Some(Err("Camera Raw cannot commit and cancel together".into()));
}
let was_closed = app.camera_raw.is_none();
let previous_view = app.ui.camera_raw_scope.clone();
if was_closed && let Err(e) = open(app, ctx) {
return Some(Err(e));
}
let result = menu_update(app, ctx, ui);
if was_closed && result.as_ref().is_some_and(Result::is_err) {
app.camera_raw = None;
app.ui.camera_raw_scope = previous_view;
}
result
}
fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Option<Result<Value, String>> {
// Stage both filter and presentation updates. No texture, preference or dialog mutation
// occurs until every part of the request has passed validation.
let d = app.camera_raw.as_ref()?;
let mut next_params = d.params.clone();
if let Some(set) = ui.get("set") {
let d = app.camera_raw.as_mut()?;
if !set.is_object() {
return Some(Err("bad Camera Raw settings: expected an object".into()));
}
let mut cur = serde_json::to_value(&d.params).unwrap_or(json!({}));
if let (Value::Object(c), Value::Object(s)) = (&mut cur, set) {
for (k, v) in s {
if !c.contains_key(k) {
return Some(Err(format!("unknown Camera Raw setting {k}")));
}
c.insert(k.clone(), v.clone());
}
}
match serde_json::from_value::<CameraRaw>(cur) {
Ok(p) => {
d.params = p;
d.render(ctx);
}
next_params = match serde_json::from_value::<CameraRaw>(cur) {
Ok(p) => p,
Err(e) => return Some(Err(format!("bad Camera Raw settings: {e}"))),
};
if let Err(e) = next_params.validate_changes(&d.original) {
return Some(Err(format!("bad Camera Raw settings: {e}")));
}
}
if let Some(b) = ui.get("before").and_then(Value::as_bool)
&& let Some(d) = app.camera_raw.as_mut()
{
let scope_update = match ui.get("scope") {
Some(scope) => match super::camera_raw_scope_ui::prepare_control(&next_params, d.selection.is_some(), &app.ui.camera_raw_scope, scope) {
Ok(update) => Some(update),
Err(e) => return Some(Err(e)),
},
None => None,
};
let d = app.camera_raw.as_mut()?;
if d.params.point_curve != next_params.point_curve {
d.curve_state = Default::default();
}
d.dirty |= d.params != next_params;
d.params = next_params;
if let Some(b) = ui.get("before").and_then(Value::as_bool) {
d.show_before = b;
}
if let Some(update) = scope_update {
update.apply(d, &mut app.ui.camera_raw_scope);
}
if d.dirty {
d.render(ctx);
}
if app.ui.camera_raw_scope.vectorscope
&& let Some(d) = app.camera_raw.as_mut()
{
d.ensure_scope(app.ui.camera_raw_scope.selected_region);
}
super::camera_raw_scope_ui::persist(app, ctx);
if ui.get("cancel").and_then(Value::as_bool) == Some(true) {
app.camera_raw = None;
return Some(Ok(json!({"cancelled": true})));
@ -182,14 +403,30 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
if ui.get("commit").and_then(Value::as_bool) == Some(true) {
return Some(commit(app));
}
Some(Ok(app.camera_raw.as_ref().map(|d| d.describe()).unwrap_or(Value::Null)))
Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null)))
}
fn commit(app: &mut PhotocraftApp) -> Result<Value, String> {
let d = app.camera_raw.take().ok_or(tl!("Camera Raw isn't open"))?;
let mut p = d.command_params();
p["layer"] = json!(d.layer.0);
app.run("filter.cameraRaw", p)
let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?;
let result = match d.target {
Target::NewFilter => {
let mut p = d.command_params();
p["layer"] = json!(d.layer.0);
app.run("filter.cameraRaw", p)?
}
Target::SmartFilter(index) => {
d.params.validate_changes(&d.original)?;
// Every setting, not just the non-defaults: setParams merges into the stored ones,
// so a control reset to its default must overwrite the old value.
let mut params = serde_json::to_value(&d.params).map_err(|e| e.to_string())?;
if let Value::Object(m) = &mut params {
m.remove("pixelScale");
}
app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": params}))?
}
};
app.camera_raw = None;
Ok(result)
}
fn temp_stops() -> Vec<Color32> {
@ -201,7 +438,11 @@ fn tint_stops() -> Vec<Color32> {
/// One labelled slider; marks the dialog dirty when it moves.
fn row(ui: &mut egui::Ui, dirty: &mut bool, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, grad: Option<&[Color32]>) {
let r = widgets::slider_row(ui, label, v, range, "", grad);
let _ = slider(ui, dirty, label, v, range, grad);
}
fn slider(ui: &mut egui::Ui, dirty: &mut bool, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, grad: Option<&[Color32]>) -> egui::Response {
let r = widgets::slider_row(ui, crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", label), v, range, "", grad);
if r.changed() {
*dirty = true;
}
@ -209,79 +450,66 @@ fn row(ui: &mut egui::Ui, dirty: &mut bool, label: &str, v: &mut f32, range: std
*v = 0.0;
*dirty = true;
}
r
}
/// A Light tone slider; Alt-dragging it shows that zone's channel clipping on the preview.
fn tone_row(ui: &mut egui::Ui, dirty: &mut bool, alt: &mut Option<ToneZone>, zone: ToneZone, v: &mut f32) {
let r = slider(ui, dirty, zone.name(), v, -zone.limit()..=zone.limit(), None);
if r.dragged() && ui.input(|i| i.modifiers.alt) {
*alt = Some(zone);
}
}
fn section(ui: &mut egui::Ui, title: &str, open: bool, body: impl FnOnce(&mut egui::Ui)) {
egui::CollapsingHeader::new(egui::RichText::new(tl!(&title)).font(FontId::proportional(13.0))).default_open(open).show(ui, body);
egui::CollapsingHeader::new(egui::RichText::new(crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", title)).font(FontId::proportional(13.0)))
.id_salt(("camera-raw-section", title))
.default_open(open)
.show(ui, body);
}
fn wheel(ui: &mut egui::Ui, dirty: &mut bool, title: &str, w: &mut Wheel) {
widgets::section_label(ui, title);
widgets::section_label(ui, crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", title));
let hs = widgets::hue_stops();
row(ui, dirty, tl!("Hue"), &mut w.hue, 0.0..=360.0, Some(&hs));
row(ui, dirty, tl!("Saturation"), &mut w.sat, 0.0..=100.0, None);
row(ui, dirty, tl!("Luminance"), &mut w.lum, -100.0..=100.0, None);
row(ui, dirty, "Hue", &mut w.hue, 0.0..=360.0, Some(&hs));
row(ui, dirty, "Saturation", &mut w.sat, 0.0..=100.0, None);
row(ui, dirty, "Luminance", &mut w.lum, -100.0..=100.0, None);
}
// The shared Curve editor must never produce a curve `CameraRaw::validate` rejects.
const _: () = assert!(crate::point_curve::MAX_POINTS == photocraft_algo::camera_raw::MAX_CURVE_POINTS && crate::point_curve::MIN_GAP >= 1.0);
/// Small point-curve editor (master channel): click to add, drag to move, right-click to delete.
fn curve_editor(ui: &mut egui::Ui, p: &mut CameraRaw, dirty: &mut bool) {
fn curve_editor(ui: &mut egui::Ui, p: &mut CameraRaw, dirty: &mut bool, state: &mut crate::state::PointCurveState) -> ERect {
let t = Tokens::get(ui.ctx());
let side = ui.available_width().min(260.0);
let (rect, resp) = ui.allocate_exact_size(vec2(side, side), Sense::click_and_drag());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 2.0, t.field);
painter.rect_filled(rect, t.radius_sm, t.field);
for i in 1..4 {
let f = i as f32 / 4.0;
painter.line_segment([pos2(rect.left() + f * side, rect.top()), pos2(rect.left() + f * side, rect.bottom())], Stroke::new(1.0, t.separator));
painter.line_segment([pos2(rect.left(), rect.top() + f * side), pos2(rect.right(), rect.top() + f * side)], Stroke::new(1.0, t.separator));
}
let to_screen = |x: f32, y: f32| pos2(rect.left() + x / 255.0 * side, rect.bottom() - y / 255.0 * side);
let from_screen = |q: Pos2| [((q.x - rect.left()) / side * 255.0).clamp(0.0, 255.0), ((rect.bottom() - q.y) / side * 255.0).clamp(0.0, 255.0)];
if p.point_curve.len() < 2 {
p.point_curve = vec![[0.0, 0.0], [255.0, 255.0]];
// The identity handles are presentation until a gesture actually edits them.
let mut points = if p.point_curve.is_empty() { vec![[0.0, 0.0], [255.0, 255.0]] } else { p.point_curve.clone() };
if crate::point_curve::interact(ui, &resp, rect, &mut points, state).changed {
p.point_curve = points.clone();
*dirty = true;
}
// Interaction.
let drag_id = ui.id().with("cr-curve-drag");
let mut dragging: Option<usize> = ui.data(|d| d.get_temp(drag_id));
if let Some(pos) = resp.interact_pointer_pos() {
let q = from_screen(pos);
if resp.drag_started() || resp.clicked() {
let near = p.point_curve.iter().position(|c| (to_screen(c[0], c[1]) - pos).length() < 8.0);
if resp.secondary_clicked() {
if let Some(i) = near.filter(|i| *i != 0 && *i + 1 != p.point_curve.len()) {
p.point_curve.remove(i);
*dirty = true;
}
} else {
dragging = Some(near.unwrap_or_else(|| {
p.point_curve.push(q);
p.point_curve.sort_by(|a, b| a[0].total_cmp(&b[0]));
*dirty = true;
p.point_curve.iter().position(|c| *c == q).unwrap_or(0)
}));
}
}
if resp.dragged()
&& let Some(i) = dragging
{
let n = p.point_curve.len();
let lo = if i == 0 { 0.0 } else { p.point_curve[i - 1][0] + 1.0 };
let hi = if i + 1 == n { 255.0 } else { p.point_curve[i + 1][0] - 1.0 };
p.point_curve[i] = [q[0].clamp(lo, hi.max(lo)), q[1]];
*dirty = true;
}
}
if resp.drag_stopped() {
dragging = None;
}
ui.data_mut(|d| d.insert_temp(drag_id, dragging));
// Curve (point curve after the parametric one).
let lut = curve_lut(&p.point_curve, 256);
let pts: Vec<Pos2> = (0..256).map(|i| to_screen(i as f32, lut[i] * 255.0)).collect();
let lut = curve_lut(&points, 256);
let pts: Vec<Pos2> = lut.iter().enumerate().map(|(i, value)| to_screen(i as f32, value * 255.0)).collect();
painter.add(egui::Shape::line(pts, Stroke::new(1.5, t.text)));
for c in &p.point_curve {
painter.circle_stroke(to_screen(c[0], c[1]), 4.0, Stroke::new(1.5, t.accent));
for (i, c) in points.iter().enumerate() {
let center = to_screen(c[0], c[1]);
if state.selected == Some(i) {
painter.circle_filled(center, 3.0, t.accent);
}
painter.circle_stroke(center, 4.0, Stroke::new(1.5, t.accent));
}
rect
}
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
@ -293,6 +521,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let mut action: Option<&str> = None;
egui::Area::new(egui::Id::new("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| {
let Some(d) = app.camera_raw.as_mut() else { return };
d.curve_rect = None;
d.scope.vectorscope_rect = None;
if d.dirty {
d.render(ctx);
}
@ -302,9 +532,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let title = ERect::from_min_size(full.min, vec2(full.width(), 30.0));
painter.rect_filled(title, 0.0, t.dock);
painter.line_segment([title.left_bottom(), title.right_bottom()], Stroke::new(1.0, t.separator));
painter.text(title.center(), Align2::CENTER_CENTER, format!("Camera Raw Filter ({})", d.layer_name), FontId::proportional(13.0), t.text);
let window_title = crate::i18n::fmt(tl!("Camera Raw Filter ({layer})"), &[("layer", &d.layer_name)]);
painter.text(title.center(), Align2::CENTER_CENTER, window_title, FontId::proportional(13.0), t.text);
let footer_h = 48.0;
let body = ERect::from_min_max(pos2(full.left(), title.bottom()), pos2(full.right(), full.bottom() - footer_h));
let scope = &mut app.ui.camera_raw_scope;
// Preview.
let view = ERect::from_min_max(body.min, pos2(body.right() - PANEL_W, body.bottom())).shrink(16.0);
painter.rect_filled(view, 0.0, t.canvas);
@ -314,87 +546,43 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let r = ERect::from_center_size(view.center(), vec2(d.pw as f32 * s, d.ph as f32 * s));
widgets::checker(&painter, r, 8.0);
painter.image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
d.scope.preview_rect = Some(r);
super::camera_raw_scope_ui::preview(ui, d, scope, r);
}
// Panels.
let right = ERect::from_min_max(pos2(body.right() - PANEL_W, body.top()), body.max);
painter.rect_filled(right, 0.0, t.dock);
painter.line_segment([right.left_top(), right.left_bottom()], Stroke::new(1.0, t.separator));
let mut props = ui.new_child(egui::UiBuilder::new().max_rect(right.shrink2(vec2(14.0, 10.0))));
// This header is outside the settings scroll area and follows the same Before/After
// selector as the texture. Both derive from the same completed preview revision.
super::camera_raw_scope_ui::header(&mut props, d, scope);
d.scope.alt_tone = None;
let mut dirty = false;
egui::ScrollArea::vertical().id_salt("camera-raw-props").show(&mut props, |ui| {
ui.spacing_mut().item_spacing.y = 4.0;
let p = &mut d.params;
let alt = &mut d.scope.alt_tone;
let (ts, tn) = (temp_stops(), tint_stops());
section(ui, tl!("Basic"), true, |ui| {
section(ui, "Light", true, |ui| {
tone_row(ui, &mut dirty, alt, ToneZone::Exposure, &mut p.exposure);
row(ui, &mut dirty, "Contrast", &mut p.contrast, -100.0..=100.0, None);
tone_row(ui, &mut dirty, alt, ToneZone::Highlights, &mut p.highlights);
tone_row(ui, &mut dirty, alt, ToneZone::Shadows, &mut p.shadows);
tone_row(ui, &mut dirty, alt, ToneZone::Whites, &mut p.whites);
tone_row(ui, &mut dirty, alt, ToneZone::Blacks, &mut p.blacks);
});
section(ui, "Color", false, |ui| {
widgets::section_label(ui, tl!("White Balance: As Shot"));
row(ui, &mut dirty, tl!("Temperature"), &mut p.temperature, -100.0..=100.0, Some(&ts));
row(ui, &mut dirty, tl!("Tint"), &mut p.tint, -100.0..=100.0, Some(&tn));
row(ui, &mut dirty, tl!("Exposure"), &mut p.exposure, -5.0..=5.0, None);
row(ui, &mut dirty, tl!("Contrast"), &mut p.contrast, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Highlights"), &mut p.highlights, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Shadows"), &mut p.shadows, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Whites"), &mut p.whites, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Blacks"), &mut p.blacks, -100.0..=100.0, None);
widgets::hairline(ui);
row(ui, &mut dirty, tl!("Texture"), &mut p.texture, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Clarity"), &mut p.clarity, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Dehaze"), &mut p.dehaze, -100.0..=100.0, None);
widgets::hairline(ui);
row(ui, &mut dirty, tl!("Vibrance"), &mut p.vibrance, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Saturation"), &mut p.saturation, -100.0..=100.0, None);
row(ui, &mut dirty, "Temperature", &mut p.temperature, -100.0..=100.0, Some(&ts));
row(ui, &mut dirty, "Tint", &mut p.tint, -100.0..=100.0, Some(&tn));
row(ui, &mut dirty, "Vibrance", &mut p.vibrance, -100.0..=100.0, None);
row(ui, &mut dirty, "Saturation", &mut p.saturation, -100.0..=100.0, None);
});
section(ui, tl!("Curve"), false, |ui| {
curve_editor(ui, p, &mut dirty);
row(ui, &mut dirty, tl!("Highlights"), &mut p.curve_highlights, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Lights"), &mut p.curve_lights, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Darks"), &mut p.curve_darks, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Shadows"), &mut p.curve_shadows, -100.0..=100.0, None);
});
section(ui, tl!("Detail"), false, |ui| {
widgets::section_label(ui, tl!("Sharpening"));
row(ui, &mut dirty, tl!("Amount"), &mut p.sharpen_amount, 0.0..=150.0, None);
row(ui, &mut dirty, tl!("Radius"), &mut p.sharpen_radius, 0.5..=3.0, None);
row(ui, &mut dirty, tl!("Detail"), &mut p.sharpen_detail, 0.0..=100.0, None);
row(ui, &mut dirty, tl!("Masking"), &mut p.sharpen_masking, 0.0..=100.0, None);
widgets::section_label(ui, tl!("Noise Reduction"));
row(ui, &mut dirty, tl!("Luminance"), &mut p.noise_luminance, 0.0..=100.0, None);
row(ui, &mut dirty, tl!("Luminance Detail"), &mut p.noise_luminance_detail, 0.0..=100.0, None);
row(ui, &mut dirty, tl!("Color"), &mut p.noise_color, 0.0..=100.0, None);
row(ui, &mut dirty, tl!("Color Detail"), &mut p.noise_color_detail, 0.0..=100.0, None);
});
section(ui, tl!("Color Mixer"), false, |ui| {
ui.horizontal(|ui| {
for (i, name) in [tl!("Hue"), tl!("Saturation"), tl!("Luminance")].iter().enumerate() {
if widgets::pill_tab(ui, name, d.mixer_tab == i).clicked() {
d.mixer_tab = i;
}
}
});
let arr = match d.mixer_tab {
0 => &mut p.hsl_hue,
1 => &mut p.hsl_sat,
_ => &mut p.hsl_lum,
};
for (k, name) in BANDS.iter().enumerate() {
let c = photocraft_algo::camera_raw::HSL_BANDS[k];
let base = hue_color(c);
let grad = [Color32::from_gray(128), base];
row(ui, &mut dirty, name, &mut arr[k], -100.0..=100.0, Some(&grad));
}
});
section(ui, tl!("Color Grading"), false, |ui| {
wheel(ui, &mut dirty, tl!("Shadows"), &mut p.grade_shadows);
wheel(ui, &mut dirty, tl!("Midtones"), &mut p.grade_midtones);
wheel(ui, &mut dirty, tl!("Highlights"), &mut p.grade_highlights);
wheel(ui, &mut dirty, tl!("Global"), &mut p.grade_global);
row(ui, &mut dirty, tl!("Blending"), &mut p.grade_blending, 0.0..=100.0, None);
row(ui, &mut dirty, tl!("Balance"), &mut p.grade_balance, -100.0..=100.0, None);
});
section(ui, tl!("Effects"), false, |ui| {
widgets::section_label(ui, tl!("Grain"));
row(ui, &mut dirty, tl!("Amount"), &mut p.grain_amount, 0.0..=100.0, None);
row(ui, &mut dirty, tl!("Size"), &mut p.grain_size, 0.0..=100.0, None);
row(ui, &mut dirty, tl!("Roughness"), &mut p.grain_roughness, 0.0..=100.0, None);
section(ui, "Effects", false, |ui| {
row(ui, &mut dirty, "Texture", &mut p.texture, -100.0..=100.0, None);
row(ui, &mut dirty, "Clarity", &mut p.clarity, -100.0..=100.0, None);
row(ui, &mut dirty, "Dehaze", &mut p.dehaze, -100.0..=100.0, None);
widgets::section_label(ui, tl!("Vignetting"));
let mut style = p.vignette_style.clone();
if widgets::dropdown(
@ -411,14 +599,73 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
p.vignette_style = style;
dirty = true;
}
row(ui, &mut dirty, tl!("Amount"), &mut p.vignette_amount, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Midpoint"), &mut p.vignette_midpoint, 0.0..=100.0, None);
row(ui, &mut dirty, tl!("Roundness"), &mut p.vignette_roundness, -100.0..=100.0, None);
row(ui, &mut dirty, tl!("Feather"), &mut p.vignette_feather, 0.0..=100.0, None);
row(ui, &mut dirty, tl!("Highlights"), &mut p.vignette_highlights, 0.0..=100.0, None);
row(ui, &mut dirty, "Amount", &mut p.vignette_amount, -100.0..=100.0, None);
row(ui, &mut dirty, "Midpoint", &mut p.vignette_midpoint, 0.0..=100.0, None);
row(ui, &mut dirty, "Roundness", &mut p.vignette_roundness, -100.0..=100.0, None);
row(ui, &mut dirty, "Feather", &mut p.vignette_feather, 0.0..=100.0, None);
row(ui, &mut dirty, "Highlights", &mut p.vignette_highlights, 0.0..=100.0, None);
widgets::section_label(ui, tl!("Grain"));
row(ui, &mut dirty, "Amount", &mut p.grain_amount, 0.0..=100.0, None);
row(ui, &mut dirty, "Size", &mut p.grain_size, 0.0..=100.0, None);
row(ui, &mut dirty, "Roughness", &mut p.grain_roughness, 0.0..=100.0, None);
});
section(ui, "Curve", false, |ui| {
d.curve_rect = Some(curve_editor(ui, p, &mut dirty, &mut d.curve_state));
row(ui, &mut dirty, "Highlights", &mut p.curve_highlights, -100.0..=100.0, None);
row(ui, &mut dirty, "Lights", &mut p.curve_lights, -100.0..=100.0, None);
row(ui, &mut dirty, "Darks", &mut p.curve_darks, -100.0..=100.0, None);
row(ui, &mut dirty, "Shadows", &mut p.curve_shadows, -100.0..=100.0, None);
});
section(ui, "Color Mixer", false, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, source) in ["Hue", "Saturation", "Luminance"].iter().enumerate() {
let name = crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", source);
let response = widgets::pill_tab(ui, name, d.mixer_tab == i);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), d.mixer_tab == i, name));
if response.clicked() {
d.mixer_tab = i;
}
}
});
let arr = match d.mixer_tab {
0 => &mut p.hsl_hue,
1 => &mut p.hsl_sat,
_ => &mut p.hsl_lum,
};
for (k, name) in BANDS.iter().enumerate() {
let c = photocraft_algo::camera_raw::HSL_BANDS[k];
let base = hue_color(c);
let grad = [Color32::from_gray(128), base];
row(ui, &mut dirty, name, &mut arr[k], -100.0..=100.0, Some(&grad));
}
});
section(ui, "Color Grading", false, |ui| {
wheel(ui, &mut dirty, "Shadows", &mut p.grade_shadows);
wheel(ui, &mut dirty, "Midtones", &mut p.grade_midtones);
wheel(ui, &mut dirty, "Highlights", &mut p.grade_highlights);
wheel(ui, &mut dirty, "Global", &mut p.grade_global);
row(ui, &mut dirty, "Blending", &mut p.grade_blending, 0.0..=100.0, None);
row(ui, &mut dirty, "Balance", &mut p.grade_balance, -100.0..=100.0, None);
});
section(ui, "Detail", false, |ui| {
widgets::section_label(ui, tl!("Sharpening"));
row(ui, &mut dirty, "Amount", &mut p.sharpen_amount, 0.0..=150.0, None);
row(ui, &mut dirty, "Radius", &mut p.sharpen_radius, 0.5..=3.0, None);
row(ui, &mut dirty, "Detail", &mut p.sharpen_detail, 0.0..=100.0, None);
row(ui, &mut dirty, "Masking", &mut p.sharpen_masking, 0.0..=100.0, None);
widgets::section_label(ui, tl!("Noise Reduction"));
row(ui, &mut dirty, "Luminance", &mut p.noise_luminance, 0.0..=100.0, None);
row(ui, &mut dirty, "Luminance Detail", &mut p.noise_luminance_detail, 0.0..=100.0, None);
row(ui, &mut dirty, "Color", &mut p.noise_color, 0.0..=100.0, None);
row(ui, &mut dirty, "Color Detail", &mut p.noise_color_detail, 0.0..=100.0, None);
});
});
super::camera_raw_scope_ui::floating(ctx, d, scope, right);
super::camera_raw_scope_ui::shortcuts(ctx, scope);
d.dirty |= dirty;
if d.dirty {
ctx.request_repaint();
}
// Footer.
let foot = ERect::from_min_max(pos2(full.left(), full.bottom() - footer_h), full.max);
painter.rect_filled(foot, 0.0, t.dock);
@ -434,6 +681,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
widgets::checkbox(&mut fu, &mut d.show_before, tl!("Before (Y)"));
fu.label(egui::RichText::new(format!("{:.0} ms", d.render_ms)).color(t.text_faint));
});
super::camera_raw_scope_ui::persist(app, ctx);
if ctx.input(|i| i.key_pressed(egui::Key::Y))
&& let Some(d) = app.camera_raw.as_mut()
{
@ -471,6 +719,83 @@ fn hue_color(h: f32) -> Color32 {
mod tests {
use super::*;
#[test]
fn histogram_follows_preview_and_view_changes_do_not_render_or_edit() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color": "#808080"})).unwrap();
let steps = app.session.active().unwrap().history.past_len();
let initial = menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap();
assert_eq!(initial["histogram"]["samples"], 64 * 48);
assert_eq!(initial["histogram"]["red"][128], 64 * 48);
let updated = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap();
assert_ne!(updated["histogram"]["red"], initial["histogram"]["red"]);
let before = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"before": true}})).unwrap().unwrap();
assert_eq!(before["histogram"]["source"], "before");
assert_eq!(before["histogram"]["red"], initial["histogram"]["red"]);
assert_eq!(before["previewRevision"], updated["previewRevision"]);
let after = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"before": false, "set": {"exposure": 1}}})).unwrap().unwrap();
assert_eq!(after["histogram"], updated["histogram"]);
assert_eq!(after["previewRevision"], updated["previewRevision"]);
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap();
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
let pixel = app.session.active().unwrap().doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().rgba(0, 0);
assert!((pixel[0] - 128.0 / 255.0).abs() < 0.0001);
}
#[test]
fn smart_filter_double_click_edits_the_stored_camera_raw_filter_in_its_own_dialog() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color": "#808080"})).unwrap();
app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
// An older editor could save stacked curve inputs; the filter must stay editable.
let legacy = json!([[0.0, 0.0], [60.0, 40.0], [60.0, 200.0], [255.0, 255.0]]);
app.run("filter.cameraRaw", json!({"exposure": 1.0, "contrast": 20})).unwrap();
app.run("layer.smartFilter.setParams", json!({"index": 0, "params": {"pointCurve": legacy}})).unwrap();
let layer = app.session.active().unwrap().active_layer.unwrap();
let smart = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(layer).unwrap().content {
photocraft_doc::LayerContent::Smart(sm) => sm.clone(),
_ => panic!("not a smart object"),
};
let steps = app.session.active().unwrap().history.past_len();
let sm = smart(&app);
crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0);
assert!(app.ui.dialogs.is_empty(), "no generic parameter form");
let opened = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {}})).unwrap().unwrap();
assert_eq!(opened["smartFilter"], 0);
assert_eq!(opened["params"]["exposure"], 1.0);
assert_eq!(opened["params"]["pointCurve"], legacy);
// The preview starts from the pixels below the filter, not the filtered cache.
let before = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"before": true}})).unwrap().unwrap();
assert_eq!(before["histogram"]["red"][128], 64 * 48);
// Resetting a control to its default must overwrite the stored value.
let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 0.0}, "before": false, "commit": true}})).unwrap();
assert!(r.is_ok(), "{r:?}");
assert!(app.camera_raw.is_none());
let sm = smart(&app);
assert_eq!(sm.smart_filters.len(), 1, "edited in place, not added");
assert_eq!(sm.smart_filters[0].params["exposure"], 0.0);
assert_eq!(sm.smart_filters[0].params["contrast"], 20.0);
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(smart(&app).smart_filters[0].params["exposure"], 1.0);
// A broken new curve is still rejected; an unknown index is an error, not a panic.
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"smartFilter": {"layer": layer.0, "index": 0}})).unwrap().unwrap();
assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"pointCurve": [[10, 10]]}}})).unwrap().is_err());
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap();
assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"smartFilter": {"layer": layer.0, "index": 7}})).unwrap().is_err());
assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"smartFilter": {"layer": "x"}})).unwrap().is_err());
}
#[test]
fn dialog_drives_the_engine() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());

View File

@ -553,6 +553,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
"brush": {"size": app.session.tools.brush.size, "hardness": app.session.tools.brush.hardness, "opacity": app.session.tools.brush.opacity},
"distort": app.distort.describe(),
"jobs": crate::jobs_ui::inspect(app),
"cameraRaw": app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)),
})
}

View File

@ -2027,3 +2027,29 @@
Rendering mode Režim vykreslování
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Laso zmrazení: tažením zmrazíte oblast, tažením s klávesou Alt ji rozmrazíte (L)
Shadow Clipping Warning (U) Varování oříznutí stínů (U)
Highlight Clipping Warning (O) Varování oříznutí světel (O)
Show Lab Color Readouts Zobrazit hodnoty barev Lab
Show Vectorscope Zobrazit vektorskop
Show Red at 3 o'clock Zobrazit červenou na třetí hodině
Show Skin Tone Indicator Zobrazit indikátor pleťových tónů
Tone Histogram Histogram tónů
Show Selected Region Zobrazit vybranou oblast
cameraRaw Light Světlo
# Camera Raw: contextual controls and window title
Camera Raw Filter ({layer}) Filtr Camera Raw ({layer})
cameraRaw Red Červené
cameraRaw Orange Oranžové
cameraRaw Yellow Žluté
cameraRaw Green Zelené
cameraRaw Aqua Tyrkysové
cameraRaw Blue Modré
cameraRaw Purple Fialové
cameraRaw Magenta Purpurové
cameraRaw Blending Míchání
cameraRaw Luminance Jas
cameraRaw Temperature Barevná teplota
cameraRaw Color Barvy

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

View File

@ -2101,3 +2101,32 @@
Rendering mode Modo de renderizado
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Lazo de congelación: arrastre para congelar un área; Alt+arrastrar para descongelarla (L)
Shadow Clipping Warning (U) Aviso de recorte de sombras (U)
Highlight Clipping Warning (O) Aviso de recorte de luces (O)
Show Lab Color Readouts Mostrar valores de color Lab
Show Vectorscope Mostrar vectorscopio
Show Red at 3 o'clock Mostrar rojo a las 3 en punto
Show Skin Tone Indicator Mostrar indicador de tono de piel
Tone Histogram Histograma tonal
Show Selected Region Mostrar región seleccionada
cameraRaw Light Luz
# Camera Raw: contextual controls and window title
Camera Raw Filter ({layer}) Filtro Camera Raw ({layer})
cameraRaw Lights Tonos claros
cameraRaw Darks Tonos oscuros
cameraRaw Red Rojos
cameraRaw Orange Naranjas
cameraRaw Yellow Amarillos
cameraRaw Green Verdes
cameraRaw Aqua Aguamarinas
cameraRaw Blue Azules
cameraRaw Purple Morados
cameraRaw Magenta Magentas
cameraRaw Blending Mezcla
cameraRaw Tint Matiz
cameraRaw Temperature Temperatura de color
cameraRaw Amount Cantidad

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

View File

@ -2017,3 +2017,22 @@
Rendering mode レンダリングモード
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) フリーズなげなわ:ドラッグで範囲をフリーズ、Alt+ドラッグで解凍 (L)
Shadow Clipping Warning (U) シャドウのクリッピング警告 (U)
Highlight Clipping Warning (O) ハイライトのクリッピング警告 (O)
Show Lab Color Readouts Lab カラー値を表示
Show Vectorscope ベクトルスコープを表示
Show Red at 3 o'clock 赤を3時の位置に表示
Show Skin Tone Indicator スキントーン指標を表示
Tone Histogram 階調ヒストグラム
Show Selected Region 選択範囲を表示
cameraRaw Light ライト
# Camera Raw: contextual controls and window title
Camera Raw Filter ({layer}) Camera Raw フィルター ({layer})
cameraRaw Lights 明部
cameraRaw Blending ブレンド
cameraRaw Blacks 黒レベル
cameraRaw Amount 適用量
cameraRaw Color カラー

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

View File

@ -553,4 +553,37 @@ mod tests {
}
}
}
/// Camera Raw includes dynamic colour-band labels and contextual labels that the generic
/// tl! scanner cannot see. Cover the partial catalog too, without claiming whole-app coverage.
#[test]
fn camera_raw_labels_are_translated_in_every_available_language() {
let sources = [include_str!("../camera_raw_ui.rs"), include_str!("../camera_raw_scope_ui.rs")];
let mut labels = std::collections::BTreeSet::new();
for source in sources {
let code = source.split("#[cfg(test)]").next().unwrap();
for marker in ["tl!(\"", "row(ui, &mut dirty, \"", "row(ui, dirty, \"", "section(ui, \"", "wheel(ui, &mut dirty, \"", "=> \""] {
for tail in code.split(marker).skip(1) {
labels.insert(tail.split('"').next().unwrap());
}
}
}
let bands = sources[0].split("const BANDS:").nth(1).unwrap().split(" = ").nth(1).unwrap().split(';').next().unwrap();
for band in bands.split('"').skip(1).step_by(2) {
labels.insert(band);
}
assert!(labels.len() >= 66, "missing Camera Raw source labels: {labels:?}");
for lang in Lang::all().filter(|l| *l != Lang::EN) {
let catalog = lang.catalog();
let missing: Vec<_> = labels.iter().filter(|s| catalog.contextual("cameraRaw", s).or_else(|| catalog.plain(s)).is_none()).collect();
assert!(missing.is_empty(), "{}: Camera Raw labels: {missing:?}", lang.code());
assert_ne!(tr_ctx(lang, "cameraRaw", "Highlights"), tr_ctx(lang, "cameraRaw", "Lights"), "{}: distinct curve regions", lang.code());
assert_ne!(tr_ctx(lang, "cameraRaw", "Shadows"), tr_ctx(lang, "cameraRaw", "Darks"), "{}: distinct curve regions", lang.code());
}
let ru = Lang::from_code("ru").unwrap();
assert_eq!(tr_ctx(ru, "cameraRaw", "Vibrance"), "Красочность");
assert_eq!(tr_ctx(ru, "cameraRaw", "Aqua"), "Голубые");
let text = fmt(tr(ru, "Camera Raw Filter ({layer})"), &[("layer", "{Background} 影像")]);
assert_eq!(text, "Фильтр Camera Raw ({Background} 影像)", "user layer names are not translated");
}
}

View File

@ -2295,3 +2295,38 @@
Rendering mode Режим отрисовки
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Лассо заморозки: перетащите, чтобы заморозить область; Alt+перетаскивание размораживает (L)
Shadow Clipping Warning (U) Предупреждение о клиппинге теней (U)
Highlight Clipping Warning (O) Предупреждение о клиппинге светов (O)
Show Lab Color Readouts Показывать значения Lab
Show Vectorscope Показывать вектороскоп
Show Red at 3 o'clock Красный в положении «3 часа»
Show Skin Tone Indicator Показывать ориентир тона кожи
Tone Histogram Тональная гистограмма
Show Selected Region Показать выделенную область
cameraRaw Light Свет
# Camera Raw: contextual controls and window title
Camera Raw Filter ({layer}) Фильтр Camera Raw ({layer})
Aqua Голубой
cameraRaw Amount Интенсивность
cameraRaw Blacks Чёрные
cameraRaw Vibrance Красочность
cameraRaw Color Grading Цветовое тонирование
cameraRaw Lights Светлые тона
cameraRaw Darks Тёмные тона
cameraRaw Global Общее
cameraRaw Red Красные
cameraRaw Orange Оранжевые
cameraRaw Yellow Жёлтые
cameraRaw Green Зелёные
cameraRaw Aqua Голубые
cameraRaw Blue Синие
cameraRaw Purple Фиолетовые
cameraRaw Magenta Пурпурные
cameraRaw Hue Тон
cameraRaw Blending Смешивание
cameraRaw Luminance Detail Детализация яркости
cameraRaw Color Detail Детализация цвета
cameraRaw Roughness Неровность

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

View File

@ -1914,3 +1914,24 @@
Erase 擦除
Right click with painting tools 使用绘画工具时右键单击
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 冻结套索:拖动以冻结区域,按住 Alt 拖动以解冻 (L)
Shadow Clipping Warning (U) 阴影剪切警告 (U)
Highlight Clipping Warning (O) 高光剪切警告 (O)
Show Lab Color Readouts 显示 Lab 颜色读数
Show Vectorscope 显示矢量示波器
Show Red at 3 o'clock 将红色显示在三点钟方向
Show Skin Tone Indicator 显示肤色指示线
Tone Histogram 色调直方图
Show Selected Region 显示选定区域
cameraRaw Light 光线
# Camera Raw: contextual controls and window title
Camera Raw Filter ({layer}) Camera Raw 滤镜({layer})
cameraRaw Lights 亮调
cameraRaw Darks 暗调
cameraRaw Blacks 黑色色阶
cameraRaw Amount 强度
cameraRaw Tint 色偏
cameraRaw Masking 蒙版保护
cameraRaw Blending 混合

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

View File

@ -2009,3 +2009,25 @@
Rendering mode 渲染模式
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 凍結套索:拖曳以凍結區域,按住 Alt 拖曳以解凍 (L)
Shadow Clipping Warning (U) 陰影剪裁警告 (U)
Highlight Clipping Warning (O) 亮部剪裁警告 (O)
Show Lab Color Readouts 顯示 Lab 色彩讀值
Show Vectorscope 顯示向量示波器
Show Red at 3 o'clock 將紅色顯示在三點鐘方向
Show Skin Tone Indicator 顯示膚色指示線
Tone Histogram 色調直方圖
Show Selected Region 顯示選取區域
cameraRaw Light 光線
# Camera Raw: contextual controls and window title
Camera Raw Filter ({layer}) Camera Raw 濾鏡({layer})
cameraRaw Lights 亮調
cameraRaw Darks 暗調
cameraRaw Blacks 黑色色階
cameraRaw Whites 白色色階
cameraRaw Amount 強度
cameraRaw Tint 色偏
cameraRaw Masking 遮色片保護
cameraRaw Blending 混合

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

View File

@ -27,6 +27,7 @@ pub mod brush_preview;
pub mod brush_resize;
pub mod brush_sections;
pub mod brushes_tab;
mod camera_raw_scope_ui;
pub mod camera_raw_ui;
pub mod canvas;
pub mod channel_view;
@ -84,6 +85,7 @@ pub mod panels;
pub mod parity;
pub mod perspective_ui;
pub mod plugin_ui;
pub mod point_curve;
pub mod prefs_ui;
pub mod preset_files_ui;
pub mod preset_panels;
@ -92,6 +94,7 @@ pub mod proxy;
pub mod puppet_ui;
pub mod rasterize_prompt;
pub mod retouch_ui;
mod rgb_histogram;
pub mod rulers;
pub mod scrollbars;
pub mod shortcut_dispatch;

View File

@ -0,0 +1,257 @@
//! Shared point-curve semantics for Camera Raw, adjustment dialogs and layer Properties.
//! Hosts own typed gesture state and rendering; this module only edits their point data.
use crate::state::{PointCurveDrag, PointCurveState};
use egui::{Key, Modifiers, Pos2, Rect, Response, Ui, pos2};
#[derive(Clone, Copy, Debug, Default)]
pub struct Interaction {
pub changed: bool,
pub commit: bool,
}
const HIT_RADIUS: f32 = 9.0;
/// Interior points disappear outside this margin and return on re-entry during the same drag.
const DRAG_OFF: f32 = 12.0;
/// Most points a curve can hold (Photoshop's limit).
pub const MAX_POINTS: usize = 16;
/// Points closer than this (input levels) are the same point.
pub const MIN_GAP: f32 = 2.0;
/// Index of the point nearest `pos` (screen) within `radius` pixels.
pub fn hit(pts: &[[f32; 2]], to_scr: impl Fn([f32; 2]) -> Pos2, pos: Pos2, radius: f32) -> Option<usize> {
pts.iter().enumerate().map(|(i, q)| (i, to_scr(*q).distance(pos))).filter(|(_, d)| *d <= radius).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
}
/// Adds a point at `v` (kept sorted). A point already at that input is selected instead; a
/// full curve takes no more points.
pub fn insert(pts: &mut Vec<[f32; 2]>, v: [f32; 2]) -> Option<usize> {
if !v.iter().all(|x| x.is_finite()) {
return None;
}
let v = [v[0].clamp(0.0, 255.0), v[1].clamp(0.0, 255.0)];
if let Some(i) = pts.iter().position(|q| (q[0] - v[0]).abs() < MIN_GAP) {
return Some(i);
}
if pts.len() >= MAX_POINTS {
return None;
}
let i = pts.iter().position(|q| q[0] > v[0]).unwrap_or(pts.len());
pts.insert(i, v);
Some(i)
}
/// Endpoints stay, and a curve keeps at least two points.
pub fn can_delete(pts: &[[f32; 2]], i: usize) -> bool {
pts.len() > 2 && i > 0 && i < pts.len().saturating_sub(1)
}
pub fn delete(pts: &mut Vec<[f32; 2]>, i: usize) -> bool {
if !can_delete(pts, i) {
return false;
}
pts.remove(i);
true
}
/// Moves point `i` to `v`, kept strictly between its neighbours' inputs.
pub fn move_to(pts: &mut [[f32; 2]], i: usize, v: [f32; 2]) -> bool {
let Some(old) = pts.get(i).copied() else { return false };
if !v.iter().chain(old.iter()).all(|x| x.is_finite()) {
return false;
}
let lo = i.checked_sub(1).and_then(|j| pts.get(j)).map_or(0.0, |p| p[0] + 1.0);
let hi = i.checked_add(1).and_then(|j| pts.get(j)).map_or(255.0, |p| p[0] - 1.0);
if !lo.is_finite() || !hi.is_finite() {
return false;
}
let new = [v[0].round().clamp(lo, hi.max(lo)), v[1].round().clamp(0.0, 255.0)];
let changed = new != old;
if let Some(point) = pts.get_mut(i) {
*point = new;
}
changed
}
/// Capture a point at button-down, preserve its grab offset, and keep it through release.
/// `commit` ends a gesture; hosts decide how that becomes document history.
pub fn interact(ui: &mut Ui, resp: &Response, graph: Rect, pts: &mut Vec<[f32; 2]>, state: &mut PointCurveState) -> Interaction {
if !graph.is_finite() || graph.width() <= 0.0 || graph.height() <= 0.0 {
*state = PointCurveState::default();
return Interaction::default();
}
state.selected = state.selected.filter(|i| *i < pts.len());
if state.drag.is_some_and(|drag| drag.index.is_some_and(|i| i >= pts.len())) {
state.drag = None;
}
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
let to_val =
|s: Pos2| [((s.x - graph.left()) / graph.width() * 255.0).clamp(0.0, 255.0), ((graph.bottom() - s.y) / graph.height() * 255.0).clamp(0.0, 255.0)];
let mut e = Interaction::default();
if resp.secondary_clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
if let Some(i) = hit(pts, to_scr, pos, HIT_RADIUS)
&& delete(pts, i)
{
e.changed = true;
e.commit = true;
}
state.selected = None;
state.drag = None;
}
// Press: hit-test where the button went down (not where a drag is first recognised).
let press = (resp.is_pointer_button_down_on() || resp.clicked_by(egui::PointerButton::Primary)) && ui.input(|i| i.pointer.primary_pressed());
// A press and release may arrive in the same native frame; egui then clears press_origin.
// A recognised click still owns its interaction position, so it can select without a drag.
let press_pos =
ui.input(|i| i.pointer.press_origin()).or_else(|| if resp.clicked_by(egui::PointerButton::Primary) { resp.interact_pointer_pos() } else { None });
if press && let Some(pos) = press_pos {
resp.request_focus();
let delete_pressed = ui.input(|i| i.modifiers.command);
match hit(pts, to_scr, pos, HIT_RADIUS) {
Some(i) if delete_pressed => {
if delete(pts, i) {
e.changed = true;
e.commit = true;
}
state.selected = None;
state.drag = None;
}
Some(i) => {
state.selected = Some(i);
let g = to_val(pos);
state.drag = pts.get(i).map(|point| PointCurveDrag { index: Some(i), offset: [point[0] - g[0], point[1] - g[1]], removed: false });
}
None if graph.contains(pos) && !delete_pressed => {
let before = pts.len();
state.selected = insert(pts, to_val(pos));
e.changed = pts.len() != before;
state.drag = state.selected.map(|i| PointCurveDrag { index: Some(i), offset: [0.0, 0.0], removed: false });
}
None => state.drag = None,
}
}
// Drag: follow the pointer; off the graph the point is removed (back on, it returns).
if (resp.is_pointer_button_down_on() || ui.input(|i| i.pointer.primary_released()))
&& !press
&& let (Some(PointCurveDrag { index, offset: off, removed }), Some(pos)) = (state.drag, ui.input(|i| i.pointer.interact_pos()))
{
let outside = !graph.expand(DRAG_OFF).contains(pos);
let g = to_val(pos);
let target = [g[0] + off[0], g[1] + off[1]];
match index {
Some(i) if outside && can_delete(pts, i) => {
delete(pts, i);
state.drag = Some(PointCurveDrag { index: None, offset: off, removed: true });
state.selected = None;
e.changed = true;
}
Some(i) => e.changed |= move_to(pts, i, target),
None if removed && !outside => {
state.selected = insert(pts, target);
state.drag = Some(PointCurveDrag { index: state.selected, offset: off, removed: true });
e.changed |= state.selected.is_some();
}
None => {}
}
}
if state.drag.is_some() && !resp.is_pointer_button_down_on() {
state.drag = None;
e.commit = true;
}
// Arrows edit this graph rather than navigating to another widget on the next frame.
ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }));
// Keys while the graph has focus: Delete/Backspace removes, arrows nudge (Shift: ×10).
if resp.has_focus()
&& let Some(i) = state.selected.filter(|i| *i < pts.len())
{
let del = ui.input_mut(|inp| inp.consume_key(Modifiers::NONE, Key::Delete) || inp.consume_key(Modifiers::NONE, Key::Backspace));
if del && delete(pts, i) {
state.selected = None;
e.changed = true;
e.commit = true;
} else {
let step = if ui.input(|inp| inp.modifiers.shift) { 10.0 } else { 1.0 };
let mut d = [0.0f32; 2];
for (k, dx, dy) in [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, 1.0), (Key::ArrowDown, 0.0, -1.0)] {
if ui.input_mut(|inp| inp.consume_key(Modifiers::NONE, k) || inp.consume_key(Modifiers::SHIFT, k)) {
d = [d[0] + dx * step, d[1] + dy * step];
}
}
let Some(point) = pts.get(i) else { return e };
let to = [point[0] + d[0], point[1] + d[1]];
if d != [0.0, 0.0] && move_to(pts, i, to) {
e.changed = true;
e.commit = true;
}
}
}
e
}
#[cfg(test)]
mod tests {
use super::*;
fn line() -> Vec<[f32; 2]> {
vec![[0.0, 0.0], [255.0, 255.0]]
}
#[test]
fn insert_keeps_order_dedupes_and_respects_the_limit() {
let mut p = line();
assert_eq!(insert(&mut p, [128.0, 150.0]), Some(1));
assert_eq!(insert(&mut p, [64.0, 40.0]), Some(1));
assert_eq!(p, vec![[0.0, 0.0], [64.0, 40.0], [128.0, 150.0], [255.0, 255.0]]);
// A click on an existing input selects that point instead of stacking a second one.
assert_eq!(insert(&mut p, [129.0, 10.0]), Some(2));
assert_eq!(p.len(), 4);
let mut full: Vec<[f32; 2]> = (0..MAX_POINTS).map(|i| [i as f32 * 10.0, 0.0]).collect();
assert_eq!(insert(&mut full, [255.0, 0.0]), None);
assert_eq!(insert(&mut p, [-50.0, 900.0]), Some(0), "clamped onto the black point");
}
#[test]
fn endpoints_and_the_last_two_points_stay() {
let mut p = vec![[0.0, 0.0], [100.0, 120.0], [255.0, 255.0]];
assert!(!delete(&mut p, 0) && !delete(&mut p, 2) && !delete(&mut p, 7));
assert!(delete(&mut p, 1));
assert_eq!(p, line());
assert!(!delete(&mut p, 1) && !delete(&mut p, 0));
assert_eq!(p.len(), 2);
}
#[test]
fn moves_stay_between_neighbours() {
let mut p = vec![[0.0, 0.0], [100.0, 120.0], [200.0, 180.0], [255.0, 255.0]];
assert!(move_to(&mut p, 1, [250.0, 300.0]));
assert_eq!(p[1], [199.0, 255.0]);
move_to(&mut p, 0, [150.0, 10.0]);
assert_eq!(p[0], [150.0, 10.0]);
move_to(&mut p, 3, [10.0, 5.0]);
assert_eq!(p[3], [201.0, 5.0]);
assert!(!move_to(&mut p, 9, [0.0, 0.0]));
}
#[test]
fn hit_test_picks_the_nearest_point_in_radius() {
let p = vec![[0.0, 0.0], [100.0, 100.0], [110.0, 110.0], [255.0, 255.0]];
let to_scr = |q: [f32; 2]| Pos2::new(q[0], 255.0 - q[1]);
assert_eq!(hit(&p, to_scr, Pos2::new(108.0, 146.0), 9.0), Some(2));
assert_eq!(hit(&p, to_scr, Pos2::new(50.0, 50.0), 9.0), None);
}
#[test]
fn hostile_points_and_indices_cannot_panic_or_pollute_a_curve() {
let mut p = line();
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert_eq!(insert(&mut p, [bad, 128.0]), None);
assert!(!move_to(&mut p, 0, [128.0, bad]));
}
assert!(!can_delete(&p, usize::MAX));
assert!(!delete(&mut p, usize::MAX));
assert!(!move_to(&mut p, usize::MAX, [128.0, 128.0]));
assert_eq!(p, line());
}
}

View File

@ -0,0 +1,270 @@
//! Camera Raw-style continuous RGB ribbons. Raw bins remain exact; display filtering is local.
use crate::theme::Tokens;
use egui::{Mesh, Rect, pos2};
use photocraft_algo::histogram::{BINS, RgbHistogram};
/// A five-bin binomial display kernel softens quantization combs. It cannot bridge
/// a wide empty range. Clipping bins are pinned and excluded from filtering adjacent bins.
fn curves(h: &RgbHistogram) -> [[f32; BINS]; 3] {
let mut curves = [[0.0; BINS]; 3];
for (out, bins) in curves.iter_mut().zip(&h.channels) {
for (i, value) in out.iter_mut().enumerate() {
let center = bins.get(i).copied().unwrap_or(0) as f32;
if i == 0 || i == BINS - 1 {
*value = center;
} else {
let sample = |j: usize| bins.get(j.clamp(1, BINS - 2)).copied().unwrap_or(0) as f32;
*value = (sample(i.saturating_sub(2)) + 4.0 * sample(i - 1) + 6.0 * center + 4.0 * sample(i + 1) + sample(i + 2)) / 16.0;
}
}
}
// One scale for RGB. Edge clipping spikes must not flatten all interior tonal detail;
// preserve them at the edges, capped at the top. A flat black/white image uses its edge peak.
let interior = curves.iter().flat_map(|c| c.iter().skip(1).take(BINS - 2)).copied().fold(0.0f32, f32::max);
let peak = if interior > 0.0 { interior } else { curves.iter().flatten().copied().fold(0.0f32, f32::max) };
let scale = if peak > 0.0 { peak * 1.1 } else { 1.0 };
for value in curves.iter_mut().flatten() {
*value = (*value / scale).min(1.0);
}
curves
}
fn quad(mesh: &mut Mesh, rect: Rect, x: [f32; 2], bottom: [f32; 2], top: [f32; 2], color: egui::Color32) {
let vertex = mesh.vertices.len() as u32; // Bounded: at most 257 spans × 4 cuts × 3 bands × 4 vertices.
for (x, height) in [(x[0], bottom[0]), (x[0], top[0]), (x[1], top[1]), (x[1], bottom[1])] {
mesh.colored_vertex(pos2(x, rect.bottom() - height * rect.height()), color);
}
mesh.add_triangle(vertex, vertex + 1, vertex + 2);
mesh.add_triangle(vertex, vertex + 2, vertex + 3);
}
/// Split where two RGB curves cross, so every subspan has a stable channel order. This gives
/// exact overlap colours without draw-order-dependent alpha stacking or cracks between bars.
fn span(mesh: &mut Mesh, rect: Rect, x: [f32; 2], a: [f32; 3], b: [f32; 3], t: &Tokens) {
let mut cuts = [0.0f32, 1.0, 1.0, 1.0, 1.0];
let mut n = 2;
for (i, (ai, bi)) in a.iter().zip(&b).enumerate() {
for (aj, bj) in a.iter().zip(&b).skip(i + 1) {
let denominator = (bi - ai) - (bj - aj);
if denominator.abs() > f32::EPSILON {
let crossing = (aj - ai) / denominator;
if crossing > 0.0
&& crossing < 1.0
&& let Some(cut) = cuts.get_mut(n)
{
*cut = crossing;
n += 1;
}
}
}
}
let Some(cuts) = cuts.get_mut(..n) else { return };
cuts.sort_unstable_by(f32::total_cmp);
for cut in cuts.windows(2) {
let (start, end) = (cut[0], cut[1]);
if end <= start {
continue;
}
let left = std::array::from_fn::<_, 3, _>(|c| a[c] + (b[c] - a[c]) * start);
let right = std::array::from_fn::<_, 3, _>(|c| a[c] + (b[c] - a[c]) * end);
let mut levels = [(0usize, 1u8), (1, 2), (2, 4)];
levels.sort_unstable_by(|(i, _), (j, _)| (left[*i] + right[*i]).total_cmp(&(left[*j] + right[*j])));
let x = [x[0] + (x[1] - x[0]) * start, x[0] + (x[1] - x[0]) * end];
let mut floor = [0.0; 2];
let mut mask = 7;
for (channel, bit) in levels {
let top = [left[channel], right[channel]];
if top[0] > floor[0] || top[1] > floor[1] {
quad(mesh, rect, x, floor, top, t.histogram_fill(mask));
}
floor = top;
mask &= !bit;
}
}
}
fn fill(rect: Rect, curves: &[[f32; BINS]; 3], t: &Tokens) -> Mesh {
let mut mesh = Mesh::default();
if !rect.is_finite() || !rect.is_positive() || !rect.size().is_finite() {
return mesh;
}
let mut previous_x = rect.left();
let mut previous = curves.map(|channel| channel[0]);
for (i, ((r, g), b)) in curves[0].iter().zip(&curves[1]).zip(&curves[2]).enumerate() {
let x = rect.left() + (i as f32 + 0.5) / BINS as f32 * rect.width();
let next = [*r, *g, *b];
span(&mut mesh, rect, [previous_x, x], previous, next, t);
previous_x = x;
previous = next;
}
span(&mut mesh, rect, [previous_x, rect.right()], previous, previous, t);
mesh
}
pub(crate) fn paint(painter: &egui::Painter, rect: Rect, h: &RgbHistogram, t: &Tokens) {
if !rect.is_finite() || !rect.is_positive() || !rect.size().is_finite() {
return;
}
let curves = curves(h);
painter.add(fill(rect, &curves, t));
for (channel, values) in curves.iter().enumerate() {
if !values.iter().any(|&v| v > 0.0) {
continue;
}
let mut points = Vec::with_capacity(BINS + 2);
points.push(pos2(rect.left(), rect.bottom() - values[0] * rect.height()));
for (i, v) in values.iter().enumerate() {
points.push(pos2(rect.left() + (i as f32 + 0.5) / BINS as f32 * rect.width(), rect.bottom() - v * rect.height()));
}
points.push(pos2(rect.right(), rect.bottom() - values[BINS - 1] * rect.height()));
painter.add(egui::Shape::line(points, egui::Stroke::new(1.0, t.histogram_color(1 << channel))));
}
}
/// One neutral tone marker, using the same RGB weights as Camera Raw's tone controls.
/// This is a constant-size overlay, independent of bin counting and normalization.
pub(crate) fn paint_sample(painter: &egui::Painter, rect: Rect, rgb: [f32; 3], t: &Tokens) {
if !rect.is_finite() || !rect.is_positive() || !rgb.iter().all(|x| x.is_finite()) {
return;
}
let painter = painter.with_clip_rect(rect);
let [r, g, b] = rgb.map(|v| v.clamp(0.0, 1.0));
let tone = 0.2126 * r + 0.7152 * g + 0.0722 * b;
let x = rect.left() + tone * rect.width();
let color = t.histogram_color(7);
let left = (x - 1.0).round();
let band = Rect::from_min_max(pos2(left, rect.top()), pos2(left + 2.0, rect.bottom()));
painter.rect_filled(band, 0.0, color.gamma_multiply(0.40));
}
#[cfg(test)]
fn mesh(rect: Rect, h: &RgbHistogram, t: &Tokens) -> Mesh {
fill(rect, &curves(h), t)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::ThemeKind;
fn color_at(mesh: &Mesh, point: egui::Pos2) -> Option<egui::Color32> {
mesh.indices.as_chunks::<3>().0.iter().find_map(|triangle| {
let a = mesh.vertices[triangle[0] as usize].pos;
let b = mesh.vertices[triangle[1] as usize].pos;
let c = mesh.vertices[triangle[2] as usize].pos;
let cross = |u: egui::Vec2, v: egui::Vec2| u.x * v.y - u.y * v.x;
if cross(b - a, c - a).abs() < 0.00001 {
return None;
}
let sides = [cross(b - a, point - a), cross(c - b, point - b), cross(a - c, point - c)];
(sides.iter().all(|&s| s >= -0.00001) || sides.iter().all(|&s| s <= 0.00001)).then_some(mesh.vertices[triangle[0] as usize].color)
})
}
#[test]
fn shared_scale_and_overlaps_are_exact_for_every_theme() {
let h = RgbHistogram { channels: [[3; BINS], [2; BINS], [1; BINS]], ..Default::default() };
let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(256.0, 110.0));
for kind in ThemeKind::ALL {
let t = Tokens::for_kind(kind);
let m = mesh(rect, &h, &t);
for (y, mask) in [(100.0, 7), (60.0, 3), (20.0, 1)] {
assert_eq!(color_at(&m, pos2(128.7, y)), Some(t.histogram_fill(mask)));
}
assert!(m.vertices.iter().all(|v| rect.contains(v.pos)));
}
}
#[test]
fn all_overlaps_and_curve_crossings_have_order_independent_colours() {
let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(256.0, 100.0));
let mut h = RgbHistogram::default();
for mask in 1..=7 {
for (c, bins) in h.channels.iter_mut().enumerate() {
bins[mask * 30..mask * 30 + 10].fill(u64::from(mask & (1 << c) != 0));
}
}
for kind in ThemeKind::ALL {
let t = Tokens::for_kind(kind);
let m = mesh(rect, &h, &t);
for mask in 1..=7 {
assert_eq!(color_at(&m, pos2((mask * 30 + 5) as f32 + 0.5, 95.0)), Some(t.histogram_fill(mask as u8)));
}
let mut m = Mesh::default();
span(&mut m, rect, [0.0, 256.0], [1.0, 0.25, 0.75], [0.25, 1.0, 0.75], &t);
for (x, y, mask) in [(25.6, 90.0, 7), (25.6, 50.0, 5), (25.6, 10.0, 1), (230.4, 50.0, 6), (230.4, 10.0, 2)] {
assert_eq!(color_at(&m, pos2(x, y)), Some(t.histogram_fill(mask)));
}
// Triangle interiors never stack: alpha fill must be independent of the channel order.
let area: f32 = m
.indices
.as_chunks::<3>()
.0
.iter()
.map(|indices| {
let a = m.vertices[indices[0] as usize].pos;
let b = m.vertices[indices[1] as usize].pos;
let c = m.vertices[indices[2] as usize].pos;
((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs() * 0.5
})
.sum();
assert!((area - 256.0 * 100.0 * (5.0 / 6.0)).abs() < 0.01);
}
}
#[test]
fn edge_spikes_do_not_flatten_or_bleed_into_interior_detail() {
let mut h = RgbHistogram { channels: [[10; BINS]; 3], ..Default::default() };
h.channels[0][0] = 1_000_000;
h.channels[1][255] = 1_000_000;
let p = curves(&h);
assert_eq!(p[0][0], 1.0);
assert_eq!(p[1][255], 1.0);
assert!((p[0][1] - 1.0 / 1.1).abs() < 0.0001);
assert!((p[1][254] - 1.0 / 1.1).abs() < 0.0001);
let mut h = RgbHistogram::default();
h.channels[0][255] = 100;
assert!(curves(&h)[0][255] > 0.9);
assert_eq!(curves(&h)[0][254], 0.0);
}
#[test]
fn empty_and_bad_rectangles_draw_no_ribbons() {
let t = Tokens::for_kind(ThemeKind::Pro);
let h = RgbHistogram::default();
assert!(mesh(Rect::from_min_max(pos2(0.0, 0.0), pos2(256.0, 100.0)), &h, &t).is_empty());
assert!(mesh(Rect::NOTHING, &h, &t).is_empty());
}
fn covers(mesh: &Mesh, point: egui::Pos2) -> bool {
color_at(mesh, point).is_some()
}
#[test]
fn periodic_quantization_gaps_do_not_make_a_sawtooth_contour() {
let mut h = RgbHistogram::default();
for channel in &mut h.channels {
for i in (30..220).step_by(3) {
channel[i] = 100;
}
}
let p = curves(&h);
let low = p[0][40..210].iter().copied().fold(f32::INFINITY, f32::min);
let high = p[0][40..210].iter().copied().fold(0.0f32, f32::max);
assert!(high - low < 0.2, "quantization comb still dominates the contour: {}", high - low);
assert_eq!(p[0][240], 0.0, "wide empty tonal ranges must stay empty");
}
#[test]
fn isolated_quantization_gaps_do_not_cut_the_ribbon_into_bars() {
let mut h = RgbHistogram::default();
for channel in &mut h.channels {
channel[119] = 100;
channel[121] = 100;
}
let original = h.clone();
let m = mesh(Rect::from_min_max(pos2(0.0, 0.0), pos2(256.0, 100.0)), &h, &Tokens::for_kind(ThemeKind::Pro));
assert!(covers(&m, pos2(120.5, 85.0)), "isolated empty bin creates a full-height black stripe");
assert!(!covers(&m, pos2(180.5, 85.0)), "a real empty tonal range must stay empty");
assert_eq!(h, original, "display filtering must never change raw statistics");
}
}

View File

@ -228,6 +228,12 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu
}
pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Camera Raw is modal like Photoshop's filter dialog: no application shortcut (Save, Undo,
// tools) runs beneath it, and it handles its own keys (Y, U, O, S). Unlike the other dialogs
// below it covers the canvas, so canvas zoom keys are blocked too.
if app.camera_raw.is_some() {
return;
}
// An open menu owns the keyboard (arrows, ↩, Esc), like a native menu.
if crate::menu_nav::is_open(ctx) {
return;

View File

@ -77,15 +77,23 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth:
if let Some(i) = index
&& resp.double_clicked()
{
open_editor(app, l.id.0, sm, i);
open_editor(app, ui.ctx(), l.id.0, sm, i);
}
}
}
/// Re-opens a smart filter's dialog with its recorded parameters; OK runs
/// `layer.smartFilter.setParams` instead of adding another filter (see `dialogs::confirm`).
pub fn open_editor(app: &mut PhotocraftApp, layer: u64, sm: &SmartObject, index: usize) {
pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: &SmartObject, index: usize) {
let Some(f) = sm.smart_filters.get(index) else { return };
// Camera Raw has its own full-window dialog rather than a generic parameter form.
if f.command == photocraft_engine::lens_cmds::RAW {
if let Err(e) = crate::camera_raw_ui::open_smart_filter(app, ctx, photocraft_doc::LayerId(layer), index) {
app.ui.status = e;
app.ui.status_error = true;
}
return;
}
if !crate::filter_dialog::has_dialog(&f.command) {
return;
}

View File

@ -3,6 +3,47 @@
use serde::{Deserialize, Serialize};
/// Shared selection and capture for a point curve; document parameters remain with the host.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PointCurveState {
pub selected: Option<usize>,
/// Captured point (None while removed outside), grab offset, and re-entry eligibility.
pub drag: Option<PointCurveDrag>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PointCurveDrag {
pub index: Option<usize>,
pub offset: [f32; 2],
pub removed: bool,
}
/// Curves editor memory (egui temp data): the edited channel and the point gesture.
#[derive(Clone, Copy, Debug, Default)]
pub struct CurvesEditorState {
pub channel: usize,
pub gesture: PointCurveState,
}
/// Camera Raw scope preferences are view state, never filter parameters or document history.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CameraRawScopeState {
pub shadows: bool,
pub highlights: bool,
pub lab: bool,
pub sampler_tool: bool,
pub samplers: Vec<[f32; 2]>,
pub vectorscope: bool,
pub selected_region: bool,
pub red_right: bool,
pub hide_skin_line: bool,
pub floating: bool,
pub floating_rect: Option<[f32; 4]>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Tool {
Move,
@ -675,6 +716,8 @@ pub struct UiState {
/// Status bar info field, Home screen (see `chrome_ui`).
#[serde(default)]
pub chrome: crate::chrome_ui::ChromeState,
#[serde(default)]
pub camera_raw_scope: CameraRawScopeState,
}
impl Default for UiState {
@ -728,6 +771,7 @@ impl Default for UiState {
notices: Vec::new(),
gpu_fallback_notice: None,
chrome: Default::default(),
camera_raw_scope: Default::default(),
}
}
}

View File

@ -107,6 +107,10 @@ pub struct Tokens {
pub tab_strip: Color32,
/// Selected list row (layers, history).
pub row_selected: Color32,
/// Scope plot background (histogram, vectorscope, clipping diagnostics).
pub histogram_bg: Color32,
/// Channel intensity of scope outlines and hue markers.
pub histogram_level: u8,
}
impl Tokens {
@ -166,6 +170,8 @@ impl Tokens {
pro: true,
tab_strip: Color32::from_rgb(38, 38, 38),
row_selected: Color32::from_rgb(82, 82, 82),
histogram_bg: Color32::from_rgb(40, 40, 40),
histogram_level: 225,
},
ThemeKind::Studio => Tokens {
kind,
@ -200,6 +206,8 @@ impl Tokens {
pro: false,
tab_strip: Color32::TRANSPARENT,
row_selected: Color32::TRANSPARENT,
histogram_bg: Color32::from_rgb(14, 14, 15),
histogram_level: 225,
},
ThemeKind::StudioLight => Tokens {
kind,
@ -234,6 +242,8 @@ impl Tokens {
pro: false,
tab_strip: Color32::TRANSPARENT,
row_selected: Color32::TRANSPARENT,
histogram_bg: Color32::from_gray(40),
histogram_level: 240,
},
ThemeKind::Classic => Tokens {
kind,
@ -268,6 +278,8 @@ impl Tokens {
pro: false,
tab_strip: Color32::from_rgb(212, 208, 200),
row_selected: Color32::from_rgb(10, 36, 106),
histogram_bg: Color32::from_gray(40),
histogram_level: 240,
},
}
}
@ -277,6 +289,30 @@ impl Tokens {
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::new("photocraft-theme"))).unwrap_or_else(|| Tokens::for_kind(ThemeKind::Studio))
}
/// Thin RGB outlines; the filled bands use the same hues with subdued coverage.
pub fn histogram_color(&self, mask: u8) -> Color32 {
let v = self.histogram_level;
Color32::from_rgb(if mask & 1 != 0 { v } else { 0 }, if mask & 2 != 0 { v } else { 0 }, if mask & 4 != 0 { v } else { 0 })
}
pub fn histogram_fill(&self, mask: u8) -> Color32 {
self.histogram_color(mask).gamma_multiply(0.3)
}
/// Analysis colours are semantic hues, independent of the application accent palette.
pub fn scope_hue(&self, h: f32, s: f32) -> Color32 {
let v = f32::from(self.histogram_level) / 255.0;
let f = |offset: f32| {
let k = (offset + h * 6.0).rem_euclid(6.0);
(v * (1.0 - s * k.min(4.0 - k).clamp(0.0, 1.0)) * 255.0) as u8
};
Color32::from_rgb(f(5.0), f(3.0), f(1.0))
}
pub fn histogram_background(&self) -> Color32 {
self.histogram_bg
}
pub fn dark(&self) -> bool {
matches!(self.kind, ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium)
}

View File

@ -341,6 +341,17 @@ fn button_impl(ui: &mut Ui, label: &str, min_width: f32, bg: Color32, fg: Color3
resp
}
/// Cached RGB data; drawing work is bounded by the 256 display bins, never the image size.
pub fn rgb_histogram(ui: &mut Ui, histogram: &photocraft_algo::histogram::RgbHistogram, height: f32) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, response) = ui.allocate_exact_size(egui::vec2(ui.available_width().max(1.0), height.max(1.0)), egui::Sense::hover());
ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background());
let plot = rect.shrink(4.0);
crate::rgb_histogram::paint(ui.painter(), plot, histogram, &t);
ui.painter().rect_stroke(rect, t.radius_sm, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
response
}
/// Small caps section label.
pub fn section_label(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx());

View File

@ -0,0 +1,128 @@
//! Camera Raw through the real app shell and its existing agent entry point.
use egui::{Event, Modifiers, MouseWheelUnit, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, theme::ThemeKind};
use serde_json::{Value, json};
fn invoke(h: &mut Harness<'_, PhotocraftApp>, params: Value) -> Value {
let ctx = h.ctx.clone();
let (request, _) = control::ControlRequest::new("ui.menu.invoke", json!({"id": "filter.cameraRaw", "params": params}));
let outcome = control::handle(h.state_mut(), &ctx, &request);
assert!(matches!(outcome, control::Outcome::Done(_)), "dialog control failed");
control::inspect(h.state(), &ctx)["cameraRaw"].clone()
}
#[test]
fn header_stays_fixed_and_resize_hover_scroll_before_do_not_rebuild_the_proxy() {
for theme in ThemeKind::ALL {
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_theme(&cc.egui_ctx, theme);
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color": "#708090"})).unwrap();
app
});
h.run_steps(2);
let original = invoke(&mut h, json!({}));
h.run_steps(3);
let label = photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), "Tone Histogram");
let header = h.query_by_label(label).expect("histogram graph").rect();
h.event(Event::PointerMoved(pos2(1050.0, 400.0)));
h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, phase: egui::TouchPhase::Move, delta: vec2(0.0, -320.0), modifiers: Modifiers::NONE });
h.run_steps(3);
assert_eq!(h.query_by_label(label).expect("histogram graph").rect(), header, "{theme:?}: histogram scrolled");
h.set_size(vec2(960.0, 600.0));
h.set_pixels_per_point(2.0);
h.run_steps(3);
let before = invoke(&mut h, json!({"ui": {"before": true}}));
h.run_steps(3);
let inspected = control::inspect(h.state(), &h.ctx);
assert_eq!(before["previewRevision"], original["previewRevision"]);
assert_eq!(inspected["cameraRaw"]["previewRevision"], original["previewRevision"]);
assert_eq!(before["histogram"]["red"], original["histogram"]["red"]);
assert!(h.query_by_label(label).expect("histogram graph").rect().bottom() < 600.0);
}
}
#[test]
fn histogram_matches_visible_preview_at_all_supported_depths_and_cancel_commit_are_reversible() {
for mode in ["rgb", "grayscale"] {
for depth in [8, 16, 32] {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 16, "height": 8, "mode": mode, "depth": depth})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color": "#808080"})).unwrap();
let original = app.session.active().unwrap().doc.clone();
let steps = app.session.active().unwrap().history.past_len();
let initial = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap();
assert_eq!(initial["histogram"]["samples"], 128);
let changed = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap();
assert_ne!(changed["histogram"]["red"], initial["histogram"]["red"]);
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap();
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original));
camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}, "commit": true}})).unwrap().unwrap();
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original));
app.run("edit.redo", json!({})).unwrap();
let committed = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap();
assert_eq!(committed["histogram"]["red"], changed["histogram"]["red"], "{mode}/{depth}: graph and committed pixels disagree");
}
}
}
#[test]
fn settings_sections_group_existing_controls_without_rendering_or_document_edits() {
assert_eq!(photocraft_ui_egui::i18n::tr_ctx(photocraft_ui_egui::i18n::Lang::from_code("ru").unwrap(), "cameraRaw", "Light"), "Свет");
for theme in ThemeKind::ALL {
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 900.0)).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_theme(&cc.egui_ctx, theme);
app.run("file.new", json!({"width":64,"height":48})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color":"#708090"})).unwrap();
app
});
let before = invoke(&mut h, json!({}));
let doc = h.state().session.active().unwrap().doc.clone();
h.run_steps(4);
let lang = photocraft_ui_egui::i18n::current();
let light = photocraft_ui_egui::i18n::tr_ctx(lang, "cameraRaw", "Light");
// The dialog translates through the `cameraRaw` context; match it in any system language.
let tr = |s| photocraft_ui_egui::i18n::tr_ctx(lang, "cameraRaw", s);
let mut y = 0.0;
for label in [light, tr("Color"), tr("Effects"), tr("Curve"), tr("Color Mixer"), tr("Color Grading"), tr("Detail")] {
let next = h.get_by_label(label).rect().top();
assert!(next > y, "section order: {label}");
y = next;
}
assert!(h.query_by_label(tr("Basic")).is_none());
assert!(h.query_by_label(tr("Temperature")).is_none());
assert!(h.query_by_label(tr("Texture")).is_none());
assert!(h.query_by_label(tr("Exposure")).is_some());
h.get_by_label(light).click();
h.run_steps(12);
h.get_by_label(tr("Color")).click();
h.run_steps(12);
for label in ["Temperature", "Tint", "Vibrance", "Saturation"] {
assert!(h.query_by_label(tr(label)).is_some(), "color control: {label}");
}
assert!(h.query_by_label(tr("Exposure")).is_none());
h.get_by_label(tr("Color")).click();
h.run_steps(12);
h.get_by_label(tr("Effects")).click();
h.run_steps(12);
for label in ["Texture", "Clarity", "Dehaze", "Vignetting", "Grain"] {
assert!(h.query_by_label(tr(label)).is_some(), "effects control: {label}");
}
let after = control::inspect(h.state(), &h.ctx);
assert_eq!(after["cameraRaw"]["params"], before["params"]);
assert_eq!(after["cameraRaw"]["previewRevision"], before["previewRevision"]);
assert!(std::sync::Arc::ptr_eq(&doc, &h.state().session.active().unwrap().doc));
}
}

View File

@ -0,0 +1,107 @@
//! Camera Raw labels and language changes through the real shared native/WASM shell.
use egui::{pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, i18n, theme::ThemeKind};
use serde_json::json;
#[test]
fn all_seven_languages_render_camera_raw_and_switch_without_resetting_view_or_filter() {
for lang in i18n::Lang::all() {
let gpu = std::env::var_os("PHOTOCRAFT_LOCALE_SNAPSHOTS").is_some();
let builder = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1440.0, 1000.0));
let builder = if gpu { builder.wgpu() } else { builder };
let mut h = builder.build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":64,"height":48})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color":"#80ff00"})).unwrap();
camera_raw_ui::open(&mut app, &cc.egui_ctx).unwrap();
app
});
h.run_steps(3);
let ctx = h.ctx.clone();
h.state_mut().set_theme(&ctx, ThemeKind::Pro);
h.state_mut().session.prefs.edit(|p| p.interface.language = lang.code().into());
h.run_steps(4);
let tr = |s| i18n::tr_ctx(lang, "cameraRaw", s);
let before = control::inspect(h.state(), &ctx)["cameraRaw"].clone();
for section in ["Light", "Color", "Effects", "Curve", "Color Mixer", "Color Grading", "Detail"] {
assert!(h.query_by_label(tr(section)).is_some(), "{}: {section}", lang.code());
}
assert!(h.query_by_label(tr("Blacks")).is_some());
h.get_by_label(tr("Light")).click();
h.run_steps(12);
h.get_by_label(tr("Color")).click();
h.run_steps(12);
for label in ["White Balance: As Shot", "Temperature", "Tint", "Vibrance", "Saturation"] {
assert!(h.query_by_label(tr(label)).is_some(), "{}: {label}", lang.code());
}
h.get_by_label(tr("Color")).click();
h.run_steps(12);
h.get_by_label(tr("Color Mixer")).click();
h.run_steps(12);
for tab in ["Hue", "Saturation", "Luminance"] {
assert!(h.get_by_label(tr(tab)).rect().right() <= 1426.0, "{}: mixer tabs overflow the panel", lang.code());
}
for band in ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"] {
assert!(h.query_by_label(tr(band)).is_some(), "{}: {band}", lang.code());
}
h.get_by_label(tr("Color Mixer")).click();
h.run_steps(12);
for (section, labels) in [
("Effects", vec!["Texture", "Clarity", "Dehaze", "Vignetting", "Grain", "Roughness"]),
("Curve", vec!["Highlights", "Lights", "Darks", "Shadows"]),
("Color Grading", vec!["Midtones", "Global", "Hue", "Saturation", "Luminance", "Blending", "Balance"]),
("Detail", vec!["Sharpening", "Masking", "Noise Reduction", "Luminance Detail", "Color Detail"]),
] {
h.query_all_by_label(tr(section)).next().unwrap().click();
h.run_steps(12);
for label in labels {
assert!(h.query_all_by_label(tr(label)).next().is_some(), "{}: {section}/{label}", lang.code());
}
// Detail is also the sharpening slider label; the section header comes first.
h.query_all_by_label(tr(section)).next().unwrap().click();
h.run_steps(12);
}
h.get_by_label(tr("Color Mixer")).click();
h.run_steps(12);
let graph = h.get_by_label(tr("Tone Histogram")).rect();
h.event(egui::Event::PointerMoved(graph.center()));
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: graph.center(), button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(3);
for label in ["Show Lab Color Readouts", "Show Vectorscope"] {
assert!(h.query_by_label(tr(label)).is_some(), "{}: scope menu/{label}", lang.code());
}
h.get_by_label(tr("Show Vectorscope")).click();
h.run_steps(3);
// The context menu stays open for its dependent scope options.
for label in ["Show Red at 3 o'clock", "Show Skin Tone Indicator"] {
assert!(h.query_by_label(tr(label)).is_some(), "{}: scope menu/{label}", lang.code());
}
let outside = pos2(100.0, 400.0);
h.event(egui::Event::PointerMoved(outside));
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: outside, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(3);
let preview = control::inspect(h.state(), &ctx)["cameraRaw"]["previewRect"].clone();
h.hover_at(pos2((preview[0].as_f64().unwrap() + preview[2].as_f64().unwrap()) as f32 * 0.5, 400.0));
h.run_steps(3);
if let Ok(dir) = std::env::var("PHOTOCRAFT_LOCALE_SNAPSHOTS") {
std::fs::create_dir_all(&dir).unwrap();
h.render().unwrap().save(std::path::Path::new(&dir).join(format!("{}.png", lang.code()))).unwrap();
}
// Changing labels must keep the same collapse IDs and open mixer, without an image edit.
h.state_mut().session.prefs.edit(|p| p.interface.language = "en".into());
h.run_steps(3);
assert!(h.query_by_label("Aqua").is_some(), "{} → en: preserve the open section", lang.code());
assert!(h.query_by_label("Exposure").is_none(), "{} → en: Light stays collapsed", lang.code());
let after = control::inspect(h.state(), &ctx)["cameraRaw"].clone();
assert_eq!(after["params"], before["params"]);
assert_eq!(after["previewRevision"], before["previewRevision"]);
assert_eq!(after["histogram"], before["histogram"]);
}
}

View File

@ -0,0 +1,752 @@
//! Real dialog/controller acceptance: interactions never make a second document edit path.
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, theme::ThemeKind};
use serde_json::{Value, json};
fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, p: Value) -> Result<Value, String> {
camera_raw_ui::menu(app, ctx, "filter.cameraRaw", &p).ok_or("missing handler")?
}
fn inspect(h: &Harness<'_, PhotocraftApp>) -> Value {
control::inspect(h.state(), &h.ctx)["cameraRaw"].clone()
}
fn rect(h: &Harness<'_, PhotocraftApp>, field: &str) -> Rect {
let v = inspect(h);
let a = v[field].as_array().unwrap();
Rect::from_min_max(pos2(a[0].as_f64().unwrap() as f32, a[1].as_f64().unwrap() as f32), pos2(a[2].as_f64().unwrap() as f32, a[3].as_f64().unwrap() as f32))
}
fn fixture(theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
fixture_render(theme, false)
}
fn fixture_render(theme: ThemeKind, gpu: bool) -> Harness<'static, PhotocraftApp> {
let builder = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0));
let builder = if gpu { builder.wgpu() } else { builder };
let mut h = builder.build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_theme(&cc.egui_ctx, theme);
app.run("file.new", json!({"width":64,"height":48})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color":"#80ff00"})).unwrap();
camera_raw_ui::open(&mut app, &cc.egui_ctx).unwrap();
app
});
h.run_steps(3);
// Startup preferences can replace the initial theme; apply the requested test theme after it.
let ctx = h.ctx.clone();
h.state_mut().set_theme(&ctx, theme);
h.run_steps(3);
assert_eq!(h.state().ui.theme, theme);
h
}
fn press(h: &mut Harness<'_, PhotocraftApp>, p: Pos2, pressed: bool, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.event(Event::PointerMoved(p));
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers });
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, PhotocraftApp>, a: Pos2, b: Pos2) {
press(h, a, true, Modifiers::NONE);
for n in 1..=4 {
h.event(Event::PointerMoved(a + (b - a) * n as f32 / 4.0));
h.run_steps(2);
}
press(h, b, false, Modifiers::NONE);
}
#[test]
fn invalid_mixed_controller_requests_leave_dialog_preferences_and_document_unchanged() {
let mut h = fixture(ThemeKind::Pro);
let ctx = h.ctx.clone();
let before = inspect(&h);
let preferences = h.state().session.prefs().dialogs.clone();
let document = h.state().session.active().unwrap().doc.clone();
for request in [
json!({"set":{"exposure":1.0},"before":true,"scope":{"sample":[-1,0]}}),
json!({"set":{"exposure":1.0},"scope":{"floatingRect":[0,0,1,1]}}),
json!({"set":{"exposure":1.0},"scope":{"unknown":true}}),
json!({"set":{"unknown":1.0}}),
json!({"before":"yes"}),
json!({"commit":true,"cancel":true}),
json!({"unknown":true}),
] {
assert!(menu(h.state_mut(), &ctx, json!({"ui":request})).is_err());
assert_eq!(inspect(&h), before);
assert_eq!(h.state().session.prefs().dialogs, preferences);
assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &document));
}
let result = menu(h.state_mut(), &ctx, json!({"ui":{"set":{"exposure":1.0},"scope":{"tone":{"zone":"exposure","delta":0.5}}}})).unwrap();
assert_eq!(result["params"]["exposure"], 1.5);
assert_eq!(result["previewRevision"].as_u64().unwrap(), before["previewRevision"].as_u64().unwrap() + 1, "one completed preview per transaction");
}
#[test]
fn invalid_requests_do_not_open_a_dialog_or_replace_scope_preferences() {
let mut h = fixture(ThemeKind::Pro);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"cancel":true}})).unwrap();
let view = h.state().ui.camera_raw_scope.clone();
let prefs = h.state().session.prefs().dialogs.clone();
for request in
[json!(null), json!([]), json!(1), json!({"ui":[]}), json!({"ui":{"set":{"pointCurve":vec![[0,0];17]}}}), json!({"ui":{"scope":{"unknown":true}}})]
{
assert!(menu(h.state_mut(), &ctx, request).is_err());
assert!(inspect(&h).is_null());
assert_eq!(h.state().ui.camera_raw_scope, view);
assert_eq!(h.state().session.prefs().dialogs, prefs);
}
}
#[test]
fn failed_commit_keeps_the_dialog_and_filter_settings_for_retry() {
let mut h = fixture(ThemeKind::Pro);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"exposure":0.5}}})).unwrap();
h.state_mut().run("layer.lockLayers", json!({"pixels":true})).unwrap();
let document = h.state().session.active().unwrap().doc.clone();
assert!(menu(h.state_mut(), &ctx, json!({"ui":{"commit":true}})).is_err());
assert_eq!(inspect(&h)["params"]["exposure"], 0.5);
assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &document));
h.state_mut().run("layer.lockLayers", json!({"pixels":false})).unwrap();
menu(h.state_mut(), &ctx, json!({"ui":{"commit":true}})).unwrap();
assert!(inspect(&h).is_null());
}
#[test]
fn malformed_curves_fail_in_both_preview_and_engine_without_an_edit() {
let mut h = fixture(ThemeKind::Pro);
let ctx = h.ctx.clone();
let before = inspect(&h);
let document = h.state().session.active().unwrap().doc.clone();
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
for points in [json!([[255, 255], [0, 0]]), json!([[0, 0], [0, 128]]), json!([[0, -1], [255, 255]]), json!(vec![[0, 0]; 17])] {
let settings = json!({field:points});
assert!(menu(h.state_mut(), &ctx, json!({"ui":{"set":settings}})).is_err());
assert!(h.state_mut().run("filter.cameraRaw", settings).is_err());
assert_eq!(inspect(&h), before);
assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &document));
}
}
}
#[test]
fn exact_clipping_rgb_lab_capped_samplers_and_reversible_tone_controls() {
for mode in ["rgb", "grayscale"] {
for depth in [8, 16, 32] {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":16,"height":8,"mode":mode,"depth":depth})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
let original = app.session.active().unwrap().doc.clone();
let history = app.session.active().unwrap().history.past_len();
let initial = menu(&mut app, &ctx, json!({})).unwrap();
for (zone, amount) in [("blacks", 20.0), ("shadows", 20.0), ("exposure", 0.5), ("highlights", 20.0), ("whites", 20.0)] {
let change = menu(&mut app, &ctx, json!({"ui":{"scope":{"tone":{"zone":zone,"delta":amount}}}})).unwrap();
assert_eq!(change["params"][zone], amount);
assert!(change["previewRevision"].as_u64().unwrap() > initial["previewRevision"].as_u64().unwrap());
menu(&mut app, &ctx, json!({"ui":{"scope":{"tone":{"zone":zone,"reset":true}}}})).unwrap();
}
for i in 0..9 {
menu(&mut app, &ctx, json!({"ui":{"scope":{"addSampler":[i as f32/9.0,0.5]}}})).unwrap();
}
let reading = menu(&mut app, &ctx, json!({"ui":{"scope":{"lab":true,"sample":[0.5,0.5],"vectorscope":true}}})).unwrap();
assert_eq!(reading["pointerReadout"]["space"], "lab");
assert_eq!(reading["samplerReadouts"].as_array().unwrap().len(), 9);
assert!(reading["pointerReadout"]["values"][0].as_f64().unwrap() > 45.0);
for bad in [
json!({"addSampler":[0.5,0.5]}),
json!({"sample":[-1,0]}),
json!({"sample":[1e30,0]}),
json!({"samplers":vec![[0.5,0.5];10]}),
json!({"removeSampler":999}),
json!({"tone":{"zone":"nonsense","delta":1}}),
json!({"floatingRect":[0,0,1,1]}),
json!({"unknown":true}),
] {
assert!(menu(&mut app, &ctx, json!({"ui":{"scope":bad}})).is_err());
}
let no_op = menu(&mut app, &ctx, json!({"ui":{"scope":{"lab":true,"redRight":true,"hideSkinLine":true,"floating":true}}})).unwrap();
assert_eq!(no_op["previewRevision"], reading["previewRevision"]);
assert_eq!(no_op["scopeRevision"], reading["scopeRevision"]);
assert_eq!(app.session.active().unwrap().history.past_len(), history);
assert!(std::sync::Arc::ptr_eq(&original, &app.session.active().unwrap().doc));
menu(&mut app, &ctx, json!({"ui":{"cancel":true}})).unwrap();
assert!(std::sync::Arc::ptr_eq(&original, &app.session.active().unwrap().doc));
menu(&mut app, &ctx, json!({"ui":{"scope":{"tone":{"zone":"exposure","delta":0.5}},"commit":true}})).unwrap();
assert_eq!(app.session.active().unwrap().history.past_len(), history + 1);
app.run("edit.undo", json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&original, &app.session.active().unwrap().doc));
}
}
}
#[test]
fn lab_readouts_respect_embedded_rgb_profile() {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":4,"height":4})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color":"#ff0000"})).unwrap();
let srgb = menu(&mut app, &ctx, json!({"ui":{"scope":{"sample":[0.5,0.5],"lab":true}}})).unwrap();
menu(&mut app, &ctx, json!({"ui":{"cancel":true}})).unwrap();
app.run("edit.assignProfile", json!({"profile":"display-p3"})).unwrap();
let p3 = menu(&mut app, &ctx, json!({"ui":{"scope":{"sample":[0.5,0.5],"lab":true}}})).unwrap();
let a = srgb["pointerReadout"]["values"][1].as_f64().unwrap();
let b = p3["pointerReadout"]["values"][1].as_f64().unwrap();
assert!((a - b).abs() > 5.0, "Lab must use document ICC: {a} vs {b}");
}
#[test]
fn real_drag_hover_warnings_sampler_motion_and_floating_scope_across_themes() {
for theme in ThemeKind::ALL {
let mut h = fixture(theme);
let initial = inspect(&h);
let history = h.state().session.active().unwrap().history.past_len();
let graph = rect(&h, "scopeRect");
for fraction in [0.05, 0.18, 0.5, 0.82, 0.95] {
let start = pos2(graph.left() + graph.width() * fraction, graph.center().y);
drag(&mut h, start, start + vec2(18.0, 0.0));
}
let after = inspect(&h);
for zone in ["blacks", "shadows", "exposure", "highlights", "whites"] {
assert!(after["params"][zone].as_f64().unwrap() > 0.0, "{theme:?}/{zone}");
}
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"samplerTool":true,"vectorscope":true,"shadows":true,"highlights":true}}})).unwrap();
h.run_steps(3);
let revision = inspect(&h)["previewRevision"].clone();
let scope_revision = inspect(&h)["scopeRevision"].clone();
let preview = rect(&h, "previewRect");
let a = preview.center();
let b = a + vec2(70.0, 30.0);
press(&mut h, a, true, Modifiers::NONE);
press(&mut h, a, false, Modifiers::NONE);
assert_eq!(inspect(&h)["scope"]["samplers"].as_array().unwrap().len(), 1, "{theme:?}: sample click");
drag(&mut h, a, b);
assert!(inspect(&h)["scope"]["samplers"][0][0].as_f64().unwrap() > 0.5);
h.event(Event::PointerMoved(preview.center()));
h.run_steps(3);
assert!(!inspect(&h)["pointerReadout"].is_null());
assert_eq!(inspect(&h)["previewRevision"], revision);
assert_eq!(inspect(&h)["scopeRevision"], scope_revision);
press(&mut h, b, true, Modifiers { alt: true, ..Modifiers::NONE });
press(&mut h, b, false, Modifiers { alt: true, ..Modifiers::NONE });
assert!(inspect(&h)["scope"]["samplers"].as_array().unwrap().is_empty());
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floating":true,"floatingRect":[50,70,400,400]}}})).unwrap();
h.run_steps(4);
assert_eq!(inspect(&h)["scope"]["floating"], true);
h.set_size(vec2(960.0, 600.0));
h.set_pixels_per_point(2.0);
h.run_steps(3);
assert_eq!(inspect(&h)["previewRevision"], revision);
assert_eq!(inspect(&h)["scopeRevision"], scope_revision);
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floating":false}}})).unwrap();
h.run_steps(3);
assert_eq!(inspect(&h)["scope"]["floating"], false);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
assert_eq!(initial["histogram"]["shadows"][2], 64 * 48);
assert_eq!(initial["histogram"]["highlights"][1], 64 * 48);
}
}
#[test]
fn real_shortcuts_triangles_menu_and_scope_preferences_roundtrip() {
let mut h = fixture(ThemeKind::Pro);
let revision = inspect(&h)["previewRevision"].clone();
for (key, field) in [(egui::Key::U, "shadows"), (egui::Key::O, "highlights"), (egui::Key::S, "samplerTool")] {
h.key_press(key);
h.run_steps(2);
assert_eq!(inspect(&h)["scope"][field], true, "{field} shortcut");
h.key_press(key);
h.run_steps(2);
assert_eq!(inspect(&h)["scope"][field], false);
}
let graph = rect(&h, "scopeRect");
for (p, field) in [(graph.min + vec2(9.0, 9.0), "shadows"), (pos2(graph.right() - 9.0, graph.top() + 9.0), "highlights")] {
press(&mut h, p, true, Modifiers::NONE);
press(&mut h, p, false, Modifiers::NONE);
assert_eq!(inspect(&h)["scope"][field], true, "{field} triangle");
}
h.event(Event::PointerMoved(graph.center()));
for pressed in [true, false] {
h.event(Event::PointerButton { pos: graph.center(), button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(3);
let label = photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), "Show Lab Color Readouts");
h.get_by_label(label).click();
h.run_steps(3);
assert_eq!(inspect(&h)["scope"]["lab"], true);
assert_eq!(inspect(&h)["previewRevision"], revision);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floating":true,"floatingRect":[60,90,400,420],"vectorscope":true}}})).unwrap();
h.run_steps(4);
let saved = h.state().session.prefs().dialogs.get("filter.cameraRaw.scope").unwrap().clone();
assert_eq!(saved["floating"], true);
assert_eq!(saved["samplers"], json!([]));
assert_eq!(saved["vectorscope"], false);
let prefs = serde_json::to_string(h.state().session.prefs()).unwrap();
let mut fresh = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
fresh.session.load_prefs_json(&prefs).unwrap();
fresh.session.prefs.edit(|p| p.dialogs.get_mut("filter.cameraRaw.scope").unwrap()["vectorscope"] = json!(true));
fresh.run("file.new", json!({"width":16,"height":16})).unwrap();
fresh.run("layer.new.layer", json!({})).unwrap();
menu(&mut fresh, &egui::Context::default(), json!({})).unwrap();
assert!(!fresh.ui.camera_raw_scope.vectorscope, "vectorscope stays opt-in when reopening Camera Raw");
assert!(fresh.ui.camera_raw_scope.floating);
assert!(fresh.ui.camera_raw_scope.lab);
assert_eq!(fresh.ui.camera_raw_scope.floating_rect, h.state().ui.camera_raw_scope.floating_rect);
}
#[test]
fn selected_region_scope_and_preview_share_the_engine_selection_boundary() {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":16,"height":8})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
app.run("select.rect", json!({"x":0,"y":0,"width":8,"height":8})).unwrap();
let original = app.session.active().unwrap().doc.clone();
menu(&mut app, &ctx, json!({})).unwrap();
let full = menu(&mut app, &ctx, json!({"ui":{"set":{"exposure":1.0},"scope":{"vectorscope":true}}})).unwrap();
assert_eq!(full["vectorscope"]["samples"], 128);
let selected = menu(&mut app, &ctx, json!({"ui":{"scope":{"selectedRegion":true}}})).unwrap();
assert_eq!(selected["vectorscope"]["samples"], 64);
assert_eq!(selected["previewRevision"], full["previewRevision"]);
assert_ne!(selected["scopeRevision"], full["scopeRevision"]);
let before = menu(&mut app, &ctx, json!({"ui":{"before":true}})).unwrap();
assert_eq!(before["vectorscope"]["before"], true);
let after = menu(&mut app, &ctx, json!({"ui":{"before":false}})).unwrap();
assert!(std::sync::Arc::ptr_eq(&original, &app.session.active().unwrap().doc));
// Atomic validation: a bad property must not apply the valid tone delta or probe.
assert!(menu(&mut app, &ctx, json!({"ui":{"scope":{"tone":{"zone":"exposure","delta":1},"sample":[0.5,0.5],"zzz":true}}})).is_err());
let unchanged = control::inspect(&app, &ctx)["cameraRaw"].clone();
assert_eq!(unchanged["params"], after["params"]);
assert_eq!(unchanged["scope"], after["scope"]);
assert_eq!(unchanged["pointerReadout"], after["pointerReadout"]);
menu(&mut app, &ctx, json!({"ui":{"commit":true}})).unwrap();
let committed = menu(&mut app, &ctx, json!({})).unwrap();
assert_eq!(committed["histogram"], after["histogram"]);
}
#[test]
fn floating_window_is_above_the_dialog_and_can_be_dragged_back_to_the_dock() {
let mut h = fixture(ThemeKind::Pro);
// No heading, grab control or undock/sample actions in the context menu.
assert!(h.query_by_label("Histogram").is_none());
let graph = rect(&h, "scopeRect");
for pressed in [true, false] {
h.event(Event::PointerMoved(graph.center()));
h.event(Event::PointerButton { pos: graph.center(), button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(3);
for source in ["Undock Scope", "Dock Scope", "Color Sampler (S)", "Clear Samplers"] {
let label = photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), source);
assert!(h.query_by_label(label).is_none(), "removed action must not appear: {source}");
}
assert!(!h.state().ui.camera_raw_scope.vectorscope);
let label = photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), "Show Vectorscope");
h.get_by_label(label).click();
h.run_steps(3);
assert!(h.state().ui.camera_raw_scope.vectorscope, "context menu still enables the optional vectorscope");
press(&mut h, pos2(200.0, 400.0), true, Modifiers::NONE);
press(&mut h, pos2(200.0, 400.0), false, Modifiers::NONE);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floating":true,"floatingRect":[60,90,400,420]}}})).unwrap();
h.run_steps(5);
let r = inspect(&h)["scope"]["floatingRect"].clone();
let title = pos2(r[0].as_f64().unwrap() as f32 + 70.0, r[1].as_f64().unwrap() as f32 + 12.0);
let revision = inspect(&h)["previewRevision"].clone();
drag(&mut h, title, title + vec2(120.0, 50.0));
let moved = inspect(&h)["scope"]["floatingRect"].clone();
assert!(moved[0].as_f64().unwrap() > r[0].as_f64().unwrap() + 80.0, "window must receive title drag above the full-screen dialog");
assert_eq!(inspect(&h)["scope"]["floating"], true);
// Resize the real floating window; geometry-only work keeps the image cache.
let corner = pos2(moved[2].as_f64().unwrap() as f32 - 1.0, moved[3].as_f64().unwrap() as f32 - 1.0);
drag(&mut h, corner, corner + vec2(50.0, 40.0));
let resized = inspect(&h)["scope"]["floatingRect"].clone();
assert!(resized[2].as_f64().unwrap() > moved[2].as_f64().unwrap() + 20.0);
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floatingRect":[180,100,530,440]}}})).unwrap();
h.run_steps(3);
let moved = inspect(&h)["scope"]["floatingRect"].clone();
assert!((moved[0].as_f64().unwrap() - 180.0).abs() < 2.0, "controller must reposition an already open panel: {moved}");
assert!((moved[2].as_f64().unwrap() - 530.0).abs() < 4.0, "controller must resize an already open panel");
let title = pos2(moved[0].as_f64().unwrap() as f32 + 70.0, moved[1].as_f64().unwrap() as f32 + 12.0);
drag(&mut h, title, pos2(1000.0, 130.0));
assert_eq!(inspect(&h)["scope"]["floating"], false, "release over dock");
assert_eq!(inspect(&h)["previewRevision"], revision);
}
#[test]
fn alt_tone_diagnostics_and_double_click_reset_follow_the_same_proxy_revision() {
let mut h = fixture(ThemeKind::Pro);
let history = h.state().session.active().unwrap().history.past_len();
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"exposure":0.5}}})).unwrap();
h.run_steps(3);
assert!(inspect(&h)["overlayRevision"].is_null());
let graph = rect(&h, "scopeRect");
let p = graph.center();
let alt = Modifiers { alt: true, ..Modifiers::NONE };
press(&mut h, p, true, alt);
h.event(Event::PointerMoved(p + vec2(15.0, 0.0)));
h.run_steps(4);
assert_eq!(inspect(&h)["overlayRevision"], inspect(&h)["previewRevision"], "Alt drag generates clipping diagnostics for the current image");
press(&mut h, p + vec2(15.0, 0.0), false, Modifiers::NONE);
// Reset through actual double-clicks, after waiting beyond the preceding gesture.
h.run_steps(30);
for _ in 0..2 {
press(&mut h, p, true, Modifiers::NONE);
press(&mut h, p, false, Modifiers::NONE);
}
assert_eq!(inspect(&h)["params"]["exposure"], 0.0);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
}
#[test]
fn both_clipping_warnings_leave_unclipped_preview_pixels_visible_on_gpu() {
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(800.0, 600.0)).wgpu().build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
app.run("file.new", json!({"width":16,"height":16})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
camera_raw_ui::open(&mut app, &cc.egui_ctx).unwrap();
app
});
h.run_steps(5);
let center = rect(&h, "previewRect").center();
let pixel = (center.x as u32, center.y as u32);
let original = h.render().unwrap().get_pixel(pixel.0, pixel.1).0;
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"shadows":true,"highlights":true}}})).unwrap();
h.run_steps(4);
let with_warnings = h.render().unwrap().get_pixel(pixel.0, pixel.1).0;
assert_eq!(with_warnings, original, "both warnings must preserve an unclipped image pixel");
}
#[test]
fn curve_drag_moves_the_existing_point_without_creating_another() {
let mut h = fixture(ThemeKind::Pro);
let graph = open_curve(&mut h);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"pointCurve":[[0,0],[128,128],[255,255]]}}})).unwrap();
h.run_steps(3);
let a = pos2(graph.left() + 128.0 / 255.0 * 260.0, graph.bottom() - 128.0 / 255.0 * 260.0);
let b = a + vec2(40.0, -32.0);
drag(&mut h, a, b);
let points = inspect(&h)["params"]["pointCurve"].clone();
assert_eq!(points.as_array().unwrap().len(), 3, "drag must keep the original point count: {points}");
assert!(points[1][0].as_f64().unwrap() > 155.0, "existing point must move horizontally: {points}");
assert!(points[1][1].as_f64().unwrap() > 150.0, "existing point must move vertically: {points}");
}
fn open_curve(h: &mut Harness<'_, PhotocraftApp>) -> Rect {
let lang = photocraft_ui_egui::i18n::current();
h.get_by_label(photocraft_ui_egui::i18n::tr_ctx(lang, "cameraRaw", "Light")).click();
h.run_steps(16);
h.get_by_label(photocraft_ui_egui::i18n::tr(lang, "Curve")).click();
h.run_steps(16);
rect(h, "curveRect")
}
fn curve_at(graph: Rect, point: [f32; 2]) -> Pos2 {
pos2(graph.left() + point[0] / 255.0 * graph.width(), graph.bottom() - point[1] / 255.0 * graph.height())
}
fn set_curve(h: &mut Harness<'_, PhotocraftApp>, points: Value) {
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"pointCurve":points}}})).unwrap();
h.run_steps(3);
}
fn curve_points(h: &Harness<'_, PhotocraftApp>) -> Value {
inspect(h)["params"]["pointCurve"].clone()
}
#[test]
fn controller_click_delivered_in_one_raw_frame_selects_an_existing_curve_point() {
let mut h = fixture(ThemeKind::Pro);
let graph = open_curve(&mut h);
set_curve(&mut h, json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]));
let at = curve_at(graph, [128.0, 128.0]);
let ctx = h.ctx.clone();
let (request, _) = control::ControlRequest::new("ui.click", json!({"x":at.x,"y":at.y}));
assert!(matches!(control::handle(h.state_mut(), &ctx, &request), control::Outcome::AfterInput));
// Harness::event splits a press/release pair across frames. Feed the controller's actual
// raw-input batch to egui to cover the native rapid-click boundary instead.
let input = egui::RawInput { screen_rect: Some(ctx.content_rect()), events: h.state_mut().take_synthetic_step(), ..Default::default() };
let mut output = ctx.run_ui(input, |ui| camera_raw_ui::show(h.state_mut(), ui.ctx()));
// This CPU interaction assertion intentionally does not present a frame.
output.textures_delta.clear();
assert_eq!(inspect(&h)["curveState"]["selected"], 1);
assert_eq!(curve_points(&h).as_array().unwrap().len(), 3);
}
#[test]
fn curve_capture_handles_fast_and_slow_motion_without_jumps_in_every_theme() {
for theme in ThemeKind::ALL {
let mut h = fixture(theme);
let graph = open_curve(&mut h);
let history = h.state().session.active().unwrap().history.past_len();
for steps in [1, 16] {
set_curve(&mut h, json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]));
let a = curve_at(graph, [128.0, 128.0]) + vec2(4.0, 3.0);
let b = a + vec2(40.0, -32.0);
let revision = inspect(&h)["previewRevision"].clone();
press(&mut h, a, true, Modifiers::NONE);
assert_eq!(curve_points(&h), json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]), "press must preserve the grab offset");
assert_eq!(inspect(&h)["previewRevision"], revision, "selection alone does not rebuild the image");
for i in 1..=steps {
h.event(Event::PointerMoved(a + (b - a) * i as f32 / steps as f32));
h.run_steps(1);
assert_eq!(curve_points(&h).as_array().unwrap().len(), 3);
assert_eq!(inspect(&h)["curveState"]["drag"]["index"], 1, "capture survives every frame");
}
press(&mut h, b, false, Modifiers::NONE);
let points = curve_points(&h);
assert!((points[1][0].as_f64().unwrap() - (128.0 + 40.0 / graph.width() as f64 * 255.0)).abs() < 1.0);
assert!((points[1][1].as_f64().unwrap() - (128.0 + 32.0 / graph.height() as f64 * 255.0)).abs() < 1.0);
assert!(inspect(&h)["curveState"]["drag"].is_null());
}
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"commit":true}})).unwrap();
assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
}
}
#[test]
fn curve_deletion_reentry_keyboard_limits_and_controller_replacement_share_capture() {
let mut h = fixture(ThemeKind::Pro);
let graph = open_curve(&mut h);
let original = h.state().session.active().unwrap().doc.clone();
set_curve(&mut h, json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]));
let a = curve_at(graph, [128.0, 128.0]);
let outside = graph.right_center() + vec2(30.0, 0.0);
press(&mut h, a, true, Modifiers::NONE);
h.event(Event::PointerMoved(outside));
h.run_steps(2);
assert_eq!(curve_points(&h).as_array().unwrap().len(), 2);
let b = curve_at(graph, [150.0, 160.0]);
h.event(Event::PointerMoved(b));
h.run_steps(2);
assert_eq!(curve_points(&h), json!([[0.0, 0.0], [150.0, 160.0], [255.0, 255.0]]));
press(&mut h, b, false, Modifiers::NONE);
h.key_press(egui::Key::ArrowUp);
h.run_steps(3);
assert_eq!(curve_points(&h)[1][1].as_f64(), Some(161.0));
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(curve_points(&h).as_array().unwrap().len(), 2);
// Same-frame native/controller clicks also insert; right-click deletes independently.
for button in [PointerButton::Primary, PointerButton::Secondary] {
h.event(Event::PointerMoved(a));
h.event(Event::PointerButton { pos: a, button, pressed: true, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: a, button, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert_eq!(curve_points(&h).as_array().unwrap().len(), if button == PointerButton::Primary { 3 } else { 2 });
}
set_curve(&mut h, json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]));
press(&mut h, a, true, Modifiers::NONE);
// A controller replacing points must cancel the old capture even with the same length.
set_curve(&mut h, json!([[0.0, 0.0], [80.0, 170.0], [255.0, 255.0]]));
assert!(inspect(&h)["curveState"]["drag"].is_null());
press(&mut h, b, false, Modifiers::NONE);
assert_eq!(curve_points(&h), json!([[0.0, 0.0], [80.0, 170.0], [255.0, 255.0]]));
set_curve(&mut h, json!([[0.0, 0.0], [100.0, 120.0], [200.0, 180.0], [255.0, 255.0]]));
drag(&mut h, curve_at(graph, [100.0, 120.0]), curve_at(graph, [240.0, 220.0]));
assert_eq!(curve_points(&h)[1][0].as_f64(), Some(199.0), "point cannot pass its neighbour");
drag(&mut h, graph.left_bottom(), graph.left_top() + vec2(-30.0, -30.0));
assert_eq!(curve_points(&h).as_array().unwrap().len(), 4, "endpoint cannot be removed by dragging off");
let full: Vec<_> = (0..16).map(|i| [i * 17, i * 17]).collect();
set_curve(&mut h, json!(full));
let p = curve_at(graph, [100.0, 180.0]);
press(&mut h, p, true, Modifiers::NONE);
press(&mut h, p, false, Modifiers::NONE);
assert_eq!(curve_points(&h).as_array().unwrap().len(), 16);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"cancel":true}})).unwrap();
assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc));
}
#[test]
fn curve_gpu_paints_the_moved_handle_in_every_theme() {
for theme in ThemeKind::ALL {
let mut h = fixture_render(theme, true);
let graph = open_curve(&mut h);
set_curve(&mut h, json!([[0, 0], [128, 128], [255, 255]]));
let before = h.render().unwrap();
let a = curve_at(graph, [128.0, 128.0]);
drag(&mut h, a, a + vec2(40.0, -32.0));
assert_eq!(curve_points(&h).as_array().unwrap().len(), 3);
let points = curve_points(&h);
let b = curve_at(graph, [points[1][0].as_f64().unwrap() as f32, points[1][1].as_f64().unwrap() as f32]);
let after = h.render().unwrap();
assert_ne!(
before.get_pixel(b.x.round() as u32, b.y.round() as u32),
after.get_pixel(b.x.round() as u32, b.y.round() as u32),
"moved handle must appear in the native GPU frame"
);
if let Ok(dir) = std::env::var("PHOTOCRAFT_CURVE_SNAPSHOTS") {
std::fs::create_dir_all(&dir).unwrap();
after.save(std::path::Path::new(&dir).join(format!("{theme:?}.png"))).unwrap();
}
}
}
#[test]
fn pixel_hover_tracks_displayed_rgb_before_after_and_keeps_analysis_cached() {
let mut h = fixture(ThemeKind::Pro);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"vectorscope":true,"lab":true}}})).unwrap();
h.run_steps(3);
let preview = rect(&h, "previewRect");
let initial = inspect(&h);
assert!(initial["hoverSample"].is_null());
h.hover_at(preview.center());
h.run_steps(3);
let sample = inspect(&h);
assert!(!sample["hoverSample"].is_null(), "ordinary hover works with sampler tool disabled");
assert_eq!(sample["scope"]["samplerTool"], false);
let rgb = &sample["hoverSample"]["rgb"];
assert!((rgb[0].as_f64().unwrap() - 128.0 / 255.0).abs() < 0.0001);
assert_eq!(rgb[1].as_f64(), Some(1.0));
assert_eq!(rgb[2].as_f64(), Some(0.0));
assert_eq!(sample["pointerReadout"]["space"], "lab", "badge RGB stays independent of Lab probe preferences");
assert!((sample["hoverSample"]["hueSaturation"][0].as_f64().unwrap() - 0.25).abs() < 0.003);
assert_eq!(sample["hoverSample"]["hueSaturation"][1].as_f64(), Some(1.0));
for n in 1..=8 {
h.hover_at(preview.center() + vec2(n as f32, n as f32));
h.run_steps(1);
}
let moved = inspect(&h);
assert_eq!(moved["previewRevision"], initial["previewRevision"]);
assert_eq!(moved["scopeRevision"], initial["scopeRevision"]);
assert_eq!(moved["histogram"], initial["histogram"]);
assert_eq!(moved["vectorscope"], initial["vectorscope"]);
let history = h.state().session.active().unwrap().history.past_len();
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"exposure":0.5}}})).unwrap();
h.run_steps(3);
assert_ne!(inspect(&h)["hoverSample"]["rgb"], sample["hoverSample"]["rgb"]);
menu(h.state_mut(), &ctx, json!({"ui":{"before":true}})).unwrap();
h.run_steps(3);
assert_eq!(inspect(&h)["hoverSample"]["rgb"], sample["hoverSample"]["rgb"]);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
h.hover_at(pos2(500.0, 20.0));
h.run_steps(3);
assert!(inspect(&h)["hoverSample"].is_null(), "leaving the image clears overlays instead of retaining a stale pixel");
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"vectorscope":false}}})).unwrap();
h.run_steps(3);
assert!(inspect(&h)["vectorscopeRect"].is_null());
}
#[test]
fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes() {
for theme in ThemeKind::ALL {
let mut h = fixture_render(theme, true);
let ctx = h.ctx.clone();
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"vectorscope":true}}})).unwrap();
h.run_steps(4);
let histogram = rect(&h, "scopeRect");
let scope = rect(&h, "vectorscopeRect");
let image = rect(&h, "previewRect");
let no_hover = h.render().unwrap();
h.hover_at(image.center());
h.run_steps(3);
let hovered = h.render().unwrap();
// RGB text/badge occupies the top centre; one tone marker crosses the histogram.
let area_changed = |r: Rect| {
(r.top().ceil() as u32..r.bottom().floor() as u32)
.any(|y| (r.left().ceil() as u32..r.right().floor() as u32).any(|x| no_hover.get_pixel(x, y) != hovered.get_pixel(x, y)))
};
let badge = Rect::from_center_size(pos2(histogram.center().x, histogram.top() + 13.0), vec2(140.0, 18.0));
let bands = Rect::from_min_max(pos2(histogram.left() + 4.0, histogram.center().y), pos2(histogram.right() - 4.0, histogram.bottom() - 4.0));
assert!(area_changed(badge), "{theme:?}: RGB badge");
assert!(area_changed(bands), "{theme:?}: translucent tone band");
let y = histogram.center().y as u32;
let changed: Vec<_> =
(histogram.left().ceil() as u32..histogram.right().floor() as u32).filter(|&x| no_hover.get_pixel(x, y) != hovered.get_pixel(x, y)).collect();
let first = *changed.first().expect("visible tone marker");
let last = *changed.last().unwrap();
assert_eq!(last - first, 1, "{theme:?}: exactly one two-pixel band, without a wide or blurred halo: {changed:?}");
assert_eq!(changed.len(), (last - first + 1) as usize, "{theme:?}: one connected band");
let rgb = inspect(&h)["hoverSample"]["rgb"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
let tone = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
let plot = histogram.shrink(4.0);
let expected = plot.left() as f64 + tone * plot.width() as f64;
assert!((f64::from(first + last) * 0.5 - expected).abs() <= 1.0, "{theme:?}: marker follows the pixel tone");
assert!(area_changed(scope), "{theme:?}: vectorscope target");
let bright_target = (scope.top().ceil() as u32..scope.bottom().floor() as u32).any(|y| {
(scope.left().ceil() as u32..scope.right().floor() as u32).any(|x| {
let before = no_hover.get_pixel(x, y).0;
let after = hovered.get_pixel(x, y).0;
let luminance = |p: [u8; 4]| p.iter().take(3).map(|c| u16::from(*c)).sum::<u16>();
luminance(after) > 600 && luminance(before) < 450
})
});
assert!(bright_target, "{theme:?}: target must contrast against the dark scope even in light themes");
if let Ok(dir) = std::env::var("PHOTOCRAFT_HOVER_SNAPSHOTS") {
std::fs::create_dir_all(&dir).unwrap();
hovered.save(std::path::Path::new(&dir).join(format!("{theme:?}.png"))).unwrap();
}
h.hover_at(pos2(500.0, 20.0));
h.run_steps(3);
let cleared = h.render().unwrap();
for r in [histogram, scope] {
for y in r.top().ceil() as u32..r.bottom().floor() as u32 {
for x in r.left().ceil() as u32..r.right().floor() as u32 {
assert_eq!(no_hover.get_pixel(x, y), cleared.get_pixel(x, y), "overlay must clear: {theme:?}");
}
}
}
}
}
#[test]
fn sampler_list_replacement_and_operations_cannot_silently_cancel_each_other() {
let mut h = fixture(ThemeKind::Pro);
let ctx = h.ctx.clone();
let combined = json!({"ui":{"scope":{"samplers":[[0.1,0.1]],"addSampler":[0.5,0.5]}}});
assert!(menu(h.state_mut(), &ctx, combined).is_err(), "replacing and editing the list in one request is ambiguous");
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"samplers":[[0.1,0.1],[0.2,0.2]]}}})).unwrap();
// Fixed order: clear, then remove, then add.
let r = menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"clearSamplers":true,"addSampler":[0.5,0.5]}}})).unwrap();
assert_eq!(r["scope"]["samplers"], json!([[0.5, 0.5]]));
}
/// Alt-drags the slider under the only visible `label` and returns the inspected dialog.
fn alt_drag_slider(h: &mut Harness<'_, PhotocraftApp>, label: &str) -> Value {
// The label sits above its 18 pt slider track.
let text = h.get_by_label(label).rect();
let at = pos2(text.left() + 80.0, text.bottom() + 12.0);
let alt = Modifiers { alt: true, ..Modifiers::NONE };
press(h, at, true, alt);
for n in 1..=3 {
h.event(Event::PointerMoved(at + vec2(10.0 * n as f32, 0.0)));
h.run_steps(2);
}
let during = inspect(h);
press(h, at + vec2(30.0, 0.0), false, Modifiers::NONE);
during
}
#[test]
fn alt_drag_diagnostics_follow_light_tone_sliders_not_same_named_ones() {
let tr = |s| photocraft_ui_egui::i18n::tr_ctx(photocraft_ui_egui::i18n::current(), "cameraRaw", s);
let mut h = fixture(ThemeKind::Pro);
let during = alt_drag_slider(&mut h, tr("Highlights"));
assert_ne!(during["params"]["highlights"], 0.0, "the drag hit the Light slider");
assert_eq!(during["overlayRevision"], during["previewRevision"], "Light tone sliders drive Alt diagnostics");
let mut h = fixture(ThemeKind::Pro);
h.get_by_label(tr("Light")).click();
h.run_steps(12);
h.get_by_label(tr("Curve")).click();
h.run_steps(12);
// The Curve section's Highlights shares its label with the Light tone slider.
let during = alt_drag_slider(&mut h, tr("Highlights"));
assert_ne!(during["params"]["curveHighlights"], 0.0, "the drag hit the Curve slider");
assert!(during["overlayRevision"].is_null(), "a same-named non-tone slider must not drive Alt diagnostics");
}

View File

@ -0,0 +1,59 @@
# Camera Raw histogram and scopes
A clean-room implementation of Camera Raw's SDR histogram interaction and vectorscope,
based on Adobe's public documentation. It does not claim numerical identity with Adobe's
develop pipeline. Automation fields are in [control-protocol.md](control-protocol.md).
| Behaviour | Implementation |
|---|---|
| RGB histogram | Continuous ribbons with additive overlap colours; display-only five-bin binomial smoothing over exact raw counts; edge spikes pinned and excluded from the auto-scale |
| Updates, Before/After | Counted from the same completed float proxy as the preview texture |
| Five tonal zones | Hover tooltip, horizontal drag and double-click reset of the existing Blacks / Shadows / Exposure / Highlights / Whites parameters |
| Clipping warnings | Exact per-channel endpoints (≤0 / ≥1); coloured triangles, U / O, blue / red preview overlays |
| Alt/Option diagnostics | Per-channel shadow or highlight clipping while Alt-dragging a Light tone slider or a histogram zone |
| Pixel hover | Tone band and RGB badge on the histogram; ICC-managed crosshair on the vectorscope |
| Readouts | RGB or Lab (document ICC profile → D50 Lab); up to nine movable probes (S, Alt-click removes) |
| Vectorscope | Hidden on opening, enabled from the context menu; 128×64 hue/saturation bins of the whole image or the document selection; red at 3 o'clock and skin-tone line options |
| Floating scopes | Resizable panel; drag it back onto the dock to restore |
| Preferences | Display options and panel geometry persist in `dialogs["filter.cameraRaw.scope"]`; probes and vectorscope visibility do not |
The settings panel is grouped as Light, Color, Effects, Curve, Color Mixer, Color Grading and
Detail, with the existing filter parameters and processing. The point Curve shares its editing
model with Image › Adjustments › Curves and layer Properties (`point_curve.rs`): button-down hit
testing, grab offsets, ordered points, endpoint protection, drag-off removal and re-entry,
keyboard nudges and deletion, at most 16 points. Camera Raw commits one `filter.cameraRaw` edit on
OK; scope interaction never edits the document. Double-clicking a Camera Raw smart filter reopens
this dialog with its stored settings and the pixels below it; OK updates that filter in place.
## Layering and bounds
Analysis lives in L2 `photocraft-algo` (`histogram`, `vectorscope`), colour transforms in L0
`photocraft-cms`, view state in `state.rs`, interaction and painting in `camera_raw_scope_ui.rs`
and `rgb_histogram.rs`. No engine commands or document fields were added.
- The preview and histogram use the existing proxy, capped at 900 px on the longest side. Each
pixel with positive finite alpha counts once; transparent and non-finite pixels are excluded,
finite values outside 0–1 count in the edge bins and the overflow counters.
- The vectorscope converts at most 8192 pixels, on a 2-D grid, through CMS. It is cached per
preview revision, Before/After and selected-region view.
- Pointer hover samples one proxy pixel and does at most one ICC conversion; it never recounts
the histogram or rebuilds the vectorscope.
- A feathered selection blends the preview with its proxy coverage, as the engine does on OK.
Thin details and masks are approximate at proxy size.
- Point curves in new settings (dialog, control channel, `filter.cameraRaw`) are validated:
empty, or 2–16 finite points in 0–255 with inputs at least one level apart. Stored Smart
Filters are not validated on load, so curves saved by earlier versions keep rendering.
`camera_raw_bench` times the CPU path on a 36 MP layer (P31: develop + histogram; P36: all SDR
scopes, probes, hover and tessellation). GPU upload and presentation are not included.
## Not covered
- HDR histograms, output and display: no suitable HDR hardware was available for validation.
Float input and counters outside 0–1 do not imply HDR support.
- Persisting Camera Raw as a PSD Smart Filter (see the file-compatibility scorecard).
- Camera Raw's own mask authoring; scopes use the document selection.
References: [Adobe, Make color and tonal adjustments](https://helpx.adobe.com/camera-raw/desktop/using/make-color-tonal-adjustments-camera.html),
[Julieanne Kost, Camera Raw shortcuts and interactions](https://jkost.com/blog/2022/10/225-shortcuts-tips-and-tricks-for-adobe-camera-raw.html).
Only public behaviour was consulted; no Adobe code, shaders, profiles or assets are included.

View File

@ -277,3 +277,53 @@ fall back on graphics errors rather than risk documents. CPU compatibility compo
on the CPU and prefers software window adapters, when available. macOS still uses Metal for
the window. A failed window renderer initialization retries once in CPU compatibility mode;
a driver process crash is detected by the startup marker on the next launch.
### Camera Raw dialog
`ui.menu.invoke {"id":"filter.cameraRaw","params":{}}` opens Camera Raw on the active
RGB/Grayscale layer. `params: {"smartFilter": {"layer": id, "index": i}}` opens it on an existing
Camera Raw smart filter instead (as double-clicking the filter in the Layers panel does): the
stored settings over the pixels below that filter, previewed through the filter mask; commit
then runs `layer.smartFilter.setParams` with every setting. `params.ui` accepts `set` (filter settings), `before`, `scope`, `commit` and
`cancel`. All parts of one request are validated before any is applied; a rejected request
leaves the settings, view state, preferences and document unchanged (and closes a dialog it
opened). Unknown `ui` or settings properties, non-boolean `before` / `commit` / `cancel`, and
`commit` together with `cancel` are errors. Point curves (`pointCurve`, `pointCurveRed`,
`pointCurveGreen`, `pointCurveBlue`) are empty or 2–16 finite points in 0–255 with inputs at
least one level apart. Commit dispatches one `filter.cameraRaw` engine command; a failed commit
keeps the dialog open for correction. Nothing else writes document history.
The response and `ui.inspect.cameraRaw` contain:
- `histogram`: `source` (`before` | `after`), `size`, `approximate`, 256-bin `red` / `green` /
`blue`, `samples`, `transparent`, `invalid`, per-channel `underflow` / `overflow`, and exact
endpoint counters `shadows` (≤0) / `highlights` (≥1). Counts describe the bounded preview
proxy in its RGB sample domain, not full-resolution or ICC display-gamut statistics.
- `previewRevision` (advances only when the proxy is re-developed), `renderMs`, `histogramMs`
(CPU only; zero on WebAssembly).
- `curveState` (`selected`, `drag`) and `curveRect: [left, top, right, bottom] | null` (null while
the Curve section is closed). Changing `set.pointCurve` cancels a stale gesture.
- `scope` (the view state below), `hasScopeSelection`, `vectorscope` (`bins`, `samples`,
`before`, `selectedRegion`) or null, `scopeMs`, `scopeRevision`, `overlayRevision`.
- `pointerReadout` and `samplerReadouts`: `position`, `space` (`rgb` 0–255 in the preview sample
space, or `lab` through the document ICC profile → D50), `values`, `alpha`; null on invisible
pixels.
- `hoverSample`: `position`, `rgb` (histogram domain) and `hueSaturation` (turns, 0–1; only
while the vectorscope is shown), or null.
- `hoveredZone`, `previewRect`, `scopeRect`, `vectorscopeRect`.
`params.ui.scope` changes presentation only: `shadows` / `highlights` (clipping warnings, U / O),
`lab`, `samplerTool` (S), `vectorscope`, `selectedRegion` (requires a document selection),
`redRight`, `hideSkinLine`, `floating`, `floatingRect: [left, top, right, bottom]` (200×150 to
4096×4096 points), `sample: [u,v] | null`, and the probe list: `samplers: [[u,v], …]` replaces it,
or `clearSamplers: true`, `removeSampler: index` and `addSampler: [u,v]` edit it, applied in that
order (the two forms can't be combined). Coordinates are normalized to the displayed proxy; at
most nine probes. `tone: {zone: "blacks|shadows|exposure|highlights|whites", delta}` adjusts the
same parameter as a histogram drag (`reset: true` sets it to 0); Exposure clamps to ±5 stops, the
others to ±100.
Hover, probe movement, theme changes and panel resizing never re-develop the image; Before/After
and region changes rebuild only the dependent analysis. Display options and the floating panel
geometry persist in preferences `dialogs["filter.cameraRaw.scope"]`; probes and vectorscope
visibility reset when the dialog opens. HDR scopes are not implemented. See
[camera-raw-histogram.md](camera-raw-histogram.md).

View File

@ -6,7 +6,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| Area | Done | Partial | Missing | Key numbers |
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 12 | 46 scenarios: 3 within budget, 22 over budget or failing, 13 tracked only, 4 not run, 8 not measurable yet (baseline 2026-10-05) |
| [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) | 1 | 8 | 18 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
| [Tools](#tools) | 5 | 13 | 12 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 4 | 19 | settings that do nothing: 60 of 135 (target 0) |
@ -52,7 +52,9 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
| P27 | Select › Modify › Feather on 24 MP (radius 50) | ≤ 100 ms p95 | 122 | 357 | 360 | 1.1 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
| P28 | Content-Aware Scale 24 MP (to 90 % width) | ≤ 3000 ms p95 | 954192 | 954192 | 954192 | 2.1 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
| P29 | Select Subject on 24 MP, repeated | ≤ 100 ms p95 | 559 | 693 | 693 | 1.4 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
| P31 | Camera Raw exposure change (proxy) | ≤ 10 ms | – | – | – | – | – | not run | [#211](https://github.com/storytold/photocraft/issues/211) |
| P33 | Brush dab on A4 300 ppi CMYK (dab to screen) | ≤ 6 ms p95 | 34.7 | 235 | 235 | 775 MB | 87 MB | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
| P36 | Camera Raw exposure change with all SDR scopes (CPU) | ≤ 10 ms p95 | – | – | – | – | – | not run | [#211](https://github.com/storytold/photocraft/issues/211) |
| P40 | Brush dab 60 px on 36 MP | track only | 1.45 | 3.19 | 3.19 | 1.3 GB | – | tracked | |
| P41 | Gaussian Blur r 40 on 36 MP | track only | 2963 | 3069 | 3069 | 1.9 GB | – | tracked | |
| P42 | Curves dialog change, layer preview, 36 MP | track only | 38.7 | 158 | 158 | 1.1 GB | – | tracked | |
@ -71,7 +73,6 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
| P20 | Open a 2 GB PSD / 4–5 GB PSB | opens with progress, editable, never blank | opens run in the background with progress (#210), but there are no generated large-file fixtures yet (#216) | [#210](https://github.com/storytold/photocraft/issues/210) |
| P23 | Warp / Puppet / Perspective preview at 4K | ≤ 16 ms per frame, commit in the background | the previews run inside the UI tools; no headless frame harness, no background commit | [#210](https://github.com/storytold/photocraft/issues/210) |
| P30 | Liquify 800 px brush on 3500×2200 at full resolution | ≤ 1 ms per update | the Liquify proxy is capped at 1600 px and the stroke path lives in the UI tool | [#211](https://github.com/storytold/photocraft/issues/211) |
| P31 | Camera Raw exposure change (proxy) | ≤ 10 ms | the proxy is built inside the dialog; no headless entry point yet | [#211](https://github.com/storytold/photocraft/issues/211) |
| P32 | Brush release on a long stroke | no hitch at mouse-up | the live renderer and the release re-render are UI-side; needs a frame harness | [#211](https://github.com/storytold/photocraft/issues/211) |
| P34 | Brush preview memory, small dab on an 8K viewport | ≤ 2 MiB | the live brush preview buffers are UI-side; no headless counter yet | [#211](https://github.com/storytold/photocraft/issues/211) |
| P35 | Marquee drag on 24 MP with Alt/Shift | ≤ 17 ms p95 | Shift/Alt marquee modifiers don't exist yet (#208) and the drag preview is UI-side | [#211](https://github.com/storytold/photocraft/issues/211) |
@ -99,7 +100,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No SVG codec or import |
| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) |
| FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp |
| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | No histogram; lens_cmds.rs:268 stores an in-app Smart Filter, PSD drops it |
| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes, clipping, tone zones, nine probes, vectorscope and floating panel; HDR deferred without test hardware. In-app Smart Filter persists, PSD drops it |
| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping |
| FILE-215-14 | User swatch model with ACO/ASE import and export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | panels.rs:954 const SWATCHES: [[u8; 3]; 40] |
| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc effects.rs:57 Contour model only |

View File

@ -114,3 +114,10 @@ documents), strings assembled with `format!` that were not converted to `fmt`/`t
per-language font fallback (the CJK fallback prefers Japanese forms), right-to-left layout,
locale-aware number and date formats, automatic language detection on the web build (native
builds read `LANG`/`LC_*`, the macOS preferred languages and the Windows user locale).
Camera Raw has explicit coverage for all seven available languages, including the partial
Simplified Chinese catalog. Its contextual `cameraRaw` entries distinguish tonal regions,
colour-band names and font-weight Light. The title uses a `{layer}` placeholder; user layer
names and the universal RGB/Lab channel symbols stay unchanged. Collapse IDs use English
source keys, so switching languages preserves open sections. Mixer tabs wrap within the
panel width. Catalog coverage and actual shell language-switch tests enforce these rules.

View File

@ -35,6 +35,12 @@ package = "photocraft-ui-egui"
args = ["--no-cpu", "--reps", "5"]
note = "36 MP document with layer effects, GPU compositor"
[[bench]]
name = "camera_raw_bench"
package = "photocraft-ui-egui"
quick_args = ["--quick"]
note = "36 MP dense RGB layer: CPU develop, histogram, ICC vectorscope, clipping, nine Lab probes and egui tessellation; excludes GPU upload/presentation"
[[bench]]
name = "type_bench"
package = "photocraft-ui-egui"
@ -343,7 +349,10 @@ id = "P31"
title = "Camera Raw exposure change (proxy)"
issue = 211
target = "≤ 10 ms"
not_measurable = "the proxy is built inside the dialog; no headless entry point yet"
bench = "camera_raw_bench"
row = "exposure proxy update"
budget_ms = 10
enforce = false # not in the 2026-10-05 baseline yet; contributor M4 run 2026-10-06: p95 5.4 ms
[[scenario]]
id = "P32"
@ -375,6 +384,15 @@ issue = 211
target = "≤ 17 ms p95"
not_measurable = "Shift/Alt marquee modifiers don't exist yet (#208) and the drag preview is UI-side"
[[scenario]]
id = "P36"
title = "Camera Raw exposure change with all SDR scopes (CPU)"
issue = 211
bench = "camera_raw_bench"
row = "interactive scope update"
budget_ms = 10
enforce = false # not in the 2026-10-05 baseline yet; contributor M4 run 2026-10-06: p95 8.0 ms
# ---- tracked (no budget yet): regressions only -------------------------------------------------
[[scenario]]

View File

@ -87,7 +87,7 @@ id = "FILE-215-12"
target = "Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter"
status = "partial"
issue = 215
note = "No histogram; lens_cmds.rs:268 stores an in-app Smart Filter, PSD drops it"
note = "Interactive RGB/Lab scopes, clipping, tone zones, nine probes, vectorscope and floating panel; HDR deferred without test hardware. In-app Smart Filter persists, PSD drops it"
[[item]]
id = "FILE-215-13"