diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 06b27576..a02e682d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,9 +20,10 @@ next to them. | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/README.md b/README.md index 9f593143..0d065514 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 66 tools

- Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 67 tools

+ Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

diff --git a/assets/icons/sampler.svg b/assets/icons/sampler.svg new file mode 100644 index 00000000..43b075f2 --- /dev/null +++ b/assets/icons/sampler.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 3855290f..d9cb687b 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -175,6 +175,9 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> { pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target()); + // Color Sampler points are view state a step may move (crop, rotation): roll them back too, so + // a failed second step can't leave them pinned to pixels the restored document no longer has. + let samplers = st.color_samplers.clone(); let r = f(s); let st = s.active_mut().ok_or(EngineError::NoDocument)?; st.history = history; @@ -189,6 +192,7 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess } Err(e) => { st.doc = before; + st.color_samplers = samplers; st.revision += 1; st.last_damage = None; Err(e) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 01f669f1..3d4b73f4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -104,6 +104,10 @@ fn has_selection(s: &Session) -> std::result::Result<(), String> { /// Image › Image Rotation by a right angle or a flip (pixels, vectors, guides, … all move). fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result { + if let Some(d) = s.active() { + let a = t.affine(f64::from(d.doc.size.width), f64::from(d.doc.size.height)); + s.set_sampler_map(a); + } s.edit(label, |doc, _| { crate::canvas_geom::turn_canvas(doc, t); Ok(()) @@ -1154,6 +1158,7 @@ fn build() -> Vec { v.extend(crate::edit_menu_cmds::specs()); v.extend(crate::fill_key_cmds::specs()); v.extend(crate::sample_cmds::specs()); + v.extend(crate::sampler_cmds::specs()); v.extend(crate::brush_key_cmds::specs()); v.extend(crate::stamp_cmds::specs()); v.extend(crate::align_cmds::specs()); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 34ad2d6a..ba7cfe61 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -179,6 +179,11 @@ fn image_size(s: &mut Session, p: &Value) -> Result { check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?; } let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); + if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { + // Color Sampler points scale with the pixels (same map `transform_geometry` applies). + let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); + s.set_sampler_map(photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); + } s.edit("Image Size", |doc, _| { if let Some(r) = dpi { doc.resolution_dpi = r.clamp(1.0, 10_000.0); @@ -336,6 +341,7 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { let dx = ((nw as f64 - ow) * ax).round() as i32; let dy = ((nh as f64 - oh) * ay).round() as i32; let ext = extension_color(s, p); + s.set_sampler_map(photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy))); s.edit("Canvas Size", |doc, _| { translate_doc(doc, "image.canvasSize", dx, dy)?; doc.size = Size::new(nw, nh); @@ -419,6 +425,7 @@ fn crop(s: &mut Session, p: &Value) -> Result { if r.is_empty() { return Err(EngineError::Other("nothing to crop: pass x/y/width/height or make a selection".into())); } + s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0))); s.edit("Crop", |doc, _| { crop_doc(doc, "image.crop", r, delete)?; doc.selection = None; @@ -552,6 +559,8 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: let target = Rect::new(x, y, x1, y1); crate::analysis_cmds::compound(s, "Crop", |s| { crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?; + // Color Sampler points follow the crop's origin shift too (the rotation moved them above). + s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0))); s.edit("Crop", |doc, _| { crop_doc(doc, CMD, target, delete)?; doc.selection = None; @@ -595,6 +604,7 @@ fn trim(s: &mut Session, p: &Value) -> Result { if side("right") { b.x1 } else { canvas.x1 }, if side("bottom") { b.y1 } else { canvas.y1 }, ); + s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0))); s.edit("Trim", |doc, _| crop_doc(doc, "image.trim", r, true))?; Ok(json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6a4354a2..1fffd0bd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -90,6 +90,7 @@ pub mod redeye_cmds; pub mod render_cmds; pub mod retouch_cmds; pub mod sample_cmds; +pub mod sampler_cmds; pub mod select_extra_cmds; pub mod selection_cmds; pub mod slice_cmds; @@ -208,6 +209,10 @@ pub struct DocState { /// A floating selection (`select.float`): the cut piece and where it floats, until dropped /// (view state: the document is unchanged until `select.drop`). pub floating: Option, + /// Color Sampler points (`view.colorSamplers.*`, #1046): numbered sample positions in document + /// pixel coordinates. Per-document view state: discarded when the document closes, never saved + /// to `.pcraft` or PSD, and not a history step. + pub color_samplers: Vec<[f64; 2]>, } impl DocState { @@ -233,6 +238,7 @@ impl DocState { fx_collapsed: Vec::new(), show_only: None, floating: None, + color_samplers: Vec::new(), } } /// The selected layers in bottom-to-top document order, always including the active layer. @@ -377,6 +383,12 @@ pub struct Session { pub authorize: Option Result<()>>, /// Background jobs (see [`jobs`]). jobs: jobs::Jobs, + /// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size, + /// rotation), for the status message. Read with [`Session::take_samplers_removed`]. + samplers_removed: usize, + /// The canvas-geometry map the next [`Session::edit`] applies to the Color Sampler points (crop, + /// Canvas Size, Image Size, rotation set it just before their edit). Consumed by the edit. + sampler_map: Option, /// Each open document's linked smart objects' files: size and modification time when last /// seen ([`link_watch`]). pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>, @@ -428,6 +440,19 @@ impl Session { } } + /// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size, + /// rotation) since this was last called, for the status message. See [`edit`](Self::edit). + pub fn take_samplers_removed(&mut self) -> usize { + std::mem::take(&mut self.samplers_removed) + } + + /// Move the Color Sampler points through `a` in the next [`Session::edit`] (crop, Canvas Size, + /// Image Size, rotation), dropping the ones that land outside the new canvas. View state: the + /// points stay pinned to their pixels, no history step. Cleared by the edit it is set for. + pub(crate) fn set_sampler_map(&mut self, a: photocraft_geom::Affine) { + self.sampler_map = Some(a); + } + pub fn sync_tools_mask_target(&mut self) { let mask = self.active().is_some_and(|st| st.channel_view.target == crate::channel_cmds::ChannelTarget::LayerMask); self.tools.target_mask(mask); @@ -552,6 +577,7 @@ impl Session { /// Apply an undoable edit to the active document. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { let restrict = self.color_restrict; + let sampler_map = self.sampler_map.take(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let before = st.doc.clone(); // Selecting layers is not a step, so the state this edit leaves behind targets what was @@ -567,6 +593,26 @@ impl Session { st.active_layer = active; fix_selection(st); channel_cmds::fix_view(st); + // A canvas-size change (crop, Canvas Size, Image Size, rotation) moves the Color Sampler + // points with their pixels and drops the ones that land outside the new canvas. View + // state: not a history step. A mapped edit (the four above) gives the exact map; any other + // size change falls back to dropping whatever is now outside. + let mut samplers_removed = 0; + if let Some(a) = sampler_map { + let bounds = st.doc.bounds(); + let n = st.color_samplers.len(); + st.color_samplers.retain_mut(|p| { + let q = a.apply(photocraft_geom::Point::new(p[0], p[1])); + *p = [q.x, q.y]; + bounds.contains(q.x.floor() as i32, q.y.floor() as i32) + }); + samplers_removed = n - st.color_samplers.len(); + } else if st.doc.size != before.size { + let bounds = st.doc.bounds(); + let n = st.color_samplers.len(); + st.color_samplers.retain(|p| bounds.contains(p[0].floor() as i32, p[1].floor() as i32)); + samplers_removed = n - st.color_samplers.len(); + } // An edit that changes no pixels (a selection, guides, a layer's name) leaves the canvas // as it is; anything else recomposites the whole document unless the command reports // its own damage after this. A new lasso selection on a large document used to cost a @@ -585,6 +631,7 @@ impl Session { st.coalesce = key; st.revision += 1; st.last_damage = unchanged.then_some(photocraft_geom::Rect::EMPTY); + self.samplers_removed += samplers_removed; if !unchanged { // Free the mask early; a stale entry would only miss (it is keyed by pixels). self.subject_cache = Default::default(); diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 2fcd30ed..2db34289 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -68,6 +68,15 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { return Ok(json!({"width": s.active().map(|d| d.doc.size.width), "height": s.active().map(|d| d.doc.size.height)})); } let bg = s.tools.background; + // Color Sampler points turn with the canvas (the same map the edit applies to the geometry). + { + let old = s.active().ok_or(EngineError::NoDocument)?.doc.size; + let new = rotated_size(old, deg); + let (sn, cs) = deg.to_radians().sin_cos(); + let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); + let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); + s.set_sampler_map(Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }); + } let size = s.edit("Rotate Canvas", |doc, _| { let old = doc.size; let new = rotated_size(old, deg); diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs new file mode 100644 index 00000000..38c35507 --- /dev/null +++ b/crates/engine/src/sampler_cmds.rs @@ -0,0 +1,442 @@ +//! Color Sampler points (#1046): numbered colour readings pinned to canvas pixels and listed in +//! the Info panel, so a fixed position can be compared instead of chasing the live pointer. +//! +//! Points are per-document view state ([`crate::DocState::color_samplers`]): they belong to their +//! document, are discarded when it closes, and are not saved to `.pcraft` or PSD. Placing, moving +//! and removing them changes no pixels and adds no History step, so they are not journaled. Each +//! point reads one composite pixel (no averaging); its values match the Info panel's pointer +//! readout (RGB 0–255 and the naive device CMYK percentages, without an ICC profile). + +use photocraft_doc::Document; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{DocState, EngineError, Result, Session}; + +/// Photoshop's Color Sampler limit: at most 10 points per document. +pub const MAX_SAMPLERS: usize = 10; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +/// Naive device CMYK (no ICC profile), in percent, as the Info panel shows it. +pub fn device_cmyk(r: f32, g: f32, b: f32) -> [i32; 4] { + let k = 1.0 - r.max(g).max(b); + let f = |v: f32| if k >= 1.0 { 0.0 } else { (1.0 - v - k) / (1.0 - k) }; + [f(r), f(g), f(b), k].map(|v| (v * 100.0).round() as i32) +} + +/// The Info-panel values for a sample point at document pixel `pos`: composite RGB 0–255 and +/// naive device CMYK percentages. One composite pixel, no averaging. +pub fn readout(doc: &Document, pos: [f64; 2]) -> ([i32; 3], [i32; 4]) { + let rgba = crate::sample_cmds::sample_color(doc, None, pos[0], pos[1], 1, crate::sample_cmds::SampleLayers::All); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as i32; + ([q(rgba[0]), q(rgba[1]), q(rgba[2])], device_cmyk(rgba[0], rgba[1], rgba[2])) +} + +/// One point as the list command reports it. +pub fn point_json(doc: &Document, index: usize, pos: [f64; 2]) -> Value { + let (rgb, cmyk) = readout(doc, pos); + json!({"index": index, "number": index + 1, "position": [pos[0], pos[1]], "rgb": rgb, "cmyk": cmyk}) +} + +fn list_json(doc: &Document, points: &[[f64; 2]]) -> Value { + json!({ + "max": MAX_SAMPLERS, + "samplers": points.iter().enumerate().map(|(i, p)| point_json(doc, i, *p)).collect::>(), + }) +} + +fn coord(cmd: &str, p: &Value, key: &str) -> Result { + p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number (document pixels)"))) +} + +/// The pixel a point at document coordinate `(x, y)` sits on, if it is inside the canvas. Whole +/// pixels only: the click's fractional position floors to the pixel it lands on. +fn pixel(doc: &Document, x: f64, y: f64) -> Option<[f64; 2]> { + let (px, py) = (x.floor(), y.floor()); + if px < f64::from(i32::MIN) || px > f64::from(i32::MAX) || py < f64::from(i32::MIN) || py > f64::from(i32::MAX) { + return None; + } + doc.bounds().contains(px as i32, py as i32).then_some([px, py]) +} + +fn index_param(cmd: &str, p: &Value) -> Result { + p.get("index").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(cmd, "pass `index` (0-based)")) +} + +/// Apply a Color Sampler change to the active document: view state, so no history step and no +/// pixels. Bumps the revision (keeping a clean document clean) so UIs refresh. +fn change(s: &mut Session, f: impl FnOnce(&mut DocState) -> Result) -> Result { + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let r = f(st)?; + let clean = st.saved_revision == st.revision; + st.revision += 1; + if clean { + st.saved_revision = st.revision; + } + st.last_damage = Some(photocraft_geom::Rect::EMPTY); + Ok(r) +} + +fn add(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "view.colorSamplers.add"; + let x = coord(CMD, p, "x")?; + let y = coord(CMD, p, "y")?; + change(s, |st| { + let pos = pixel(&st.doc, x, y).ok_or_else(|| EngineError::Other("the sample point is outside the canvas".into()))?; + if st.color_samplers.len() >= MAX_SAMPLERS { + return Err(EngineError::Other(format!("Color Sampler limit reached: at most {MAX_SAMPLERS} points per document"))); + } + st.color_samplers.push(pos); + let i = st.color_samplers.len() - 1; + Ok(json!({"index": i, "count": st.color_samplers.len(), "point": point_json(&st.doc, i, pos)})) + }) +} + +fn move_point(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "view.colorSamplers.move"; + let i = index_param(CMD, p)?; + let x = coord(CMD, p, "x")?; + let y = coord(CMD, p, "y")?; + change(s, |st| { + let n = st.color_samplers.len(); + if i >= n { + return Err(bad(CMD, format!("no sample point {i} (the document has {n})"))); + } + let pos = pixel(&st.doc, x, y).ok_or_else(|| EngineError::Other("the sample point is outside the canvas".into()))?; + st.color_samplers[i] = pos; + Ok(json!({"index": i, "point": point_json(&st.doc, i, pos)})) + }) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "view.colorSamplers.delete"; + let i = index_param(CMD, p)?; + change(s, |st| { + let n = st.color_samplers.len(); + if i >= n { + return Err(bad(CMD, format!("no sample point {i} (the document has {n})"))); + } + st.color_samplers.remove(i); + Ok(json!({"deleted": 1, "remaining": st.color_samplers.len()})) + }) +} + +fn clear(s: &mut Session, _p: &Value) -> Result { + change(s, |st| { + let n = st.color_samplers.len(); + st.color_samplers.clear(); + Ok(json!({"deleted": n, "remaining": 0})) + }) +} + +fn list(s: &mut Session, _p: &Value) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + Ok(list_json(&st.doc, &st.color_samplers)) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "view.colorSamplers.add", + label: "Add Color Sampler", + menu: &[], + shortcut: None, + params: r##"{"x":f64,"y":f64} → the new point {index, number, position, rgb, cmyk}; errors at the 10-point limit or off the canvas"##, + enabled: has_doc, + run: add, + journal: false, + }, + CommandSpec { + id: "view.colorSamplers.move", + label: "Move Color Sampler", + menu: &[], + shortcut: None, + params: r##"{"index":n,"x":f64,"y":f64}"##, + enabled: has_doc, + run: move_point, + journal: false, + }, + CommandSpec { + id: "view.colorSamplers.delete", + label: "Delete Color Sampler", + menu: &[], + shortcut: None, + params: r##"{"index":n}"##, + enabled: has_doc, + run: delete, + journal: false, + }, + CommandSpec { + id: "view.colorSamplers.clear", + label: "Clear Color Samplers", + menu: &[], + shortcut: None, + params: "{}", + enabled: has_doc, + run: clear, + journal: false, + }, + CommandSpec { + id: "view.colorSamplers.list", + label: "Color Samplers", + menu: &[], + shortcut: None, + params: r##"{} → {max, samplers:[{index, number, position, rgb, cmyk}]}"##, + enabled: has_doc, + run: list, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Layer, Size}; + use photocraft_geom::Rect; + + /// A 100×80 white document. + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 80, "background": "white"})).unwrap(); + s + } + + fn list(s: &mut Session) -> Value { + s.execute("view.colorSamplers.list", json!({})).unwrap() + } + + fn points(s: &mut Session) -> Vec { + list(s)["samplers"].as_array().cloned().unwrap_or_default() + } + + #[test] + fn add_reads_the_composite_pixel_and_lists_it() { + let mut s = session(); + let r = s.execute("view.colorSamplers.add", json!({"x": 5, "y": 6})).unwrap(); + assert_eq!(r["index"], 0); + assert_eq!(r["count"], 1); + assert_eq!(r["point"]["number"], 1); + assert_eq!(r["point"]["position"], json!([5.0, 6.0])); + assert_eq!(r["point"]["rgb"], json!([255, 255, 255])); + assert_eq!(r["point"]["cmyk"], json!([0, 0, 0, 0])); + let l = list(&mut s); + assert_eq!(l["max"], 10); + assert_eq!(l["samplers"].as_array().unwrap().len(), 1); + assert_eq!(l["samplers"][0]["position"], json!([5.0, 6.0])); + } + + #[test] + fn fractional_clicks_floor_to_the_pixel() { + let mut s = session(); + let r = s.execute("view.colorSamplers.add", json!({"x": 5.9, "y": 6.1})).unwrap(); + assert_eq!(r["point"]["position"], json!([5.0, 6.0])); + } + + #[test] + fn add_reads_an_actual_painted_pixel() { + // A black pixel at (3, 3) of an otherwise white document. + let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &black); + Ok(()) + }) + .unwrap(); + let r = s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + assert_eq!(r["point"]["rgb"], json!([0, 0, 0])); + assert_eq!(r["point"]["cmyk"], json!([0, 0, 0, 100])); + // Painting over the point changes its readout (the list is computed live). + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let red = photocraft_raster::from_rgba(&surf.format(), [1.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &red); + Ok(()) + }) + .unwrap(); + assert_eq!(points(&mut s)[0]["rgb"], json!([255, 0, 0])); + } + + #[test] + fn ten_points_then_the_limit() { + let mut s = session(); + for i in 0..10 { + s.execute("view.colorSamplers.add", json!({"x": i, "y": 0})).unwrap(); + } + assert_eq!(points(&mut s).len(), 10); + let err = s.execute("view.colorSamplers.add", json!({"x": 20, "y": 0})).unwrap_err().to_string(); + assert!(err.contains("limit"), "{err}"); + assert_eq!(points(&mut s).len(), 10); + } + + #[test] + fn move_delete_and_clear() { + let mut s = session(); + s.execute("view.colorSamplers.add", json!({"x": 5, "y": 5})).unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let r = s.execute("view.colorSamplers.move", json!({"index": 0, "x": 10, "y": 20})).unwrap(); + assert_eq!(r["point"]["position"], json!([10.0, 20.0])); + assert_eq!(points(&mut s)[0]["position"], json!([10.0, 20.0])); + s.execute("view.colorSamplers.delete", json!({"index": 1})).unwrap(); + assert_eq!(points(&mut s).len(), 1); + // The remaining point is the moved #1. + assert_eq!(points(&mut s)[0]["position"], json!([10.0, 20.0])); + let r = s.execute("view.colorSamplers.clear", json!({})).unwrap(); + assert_eq!(r["deleted"], 1); + assert!(points(&mut s).is_empty()); + } + + #[test] + fn placing_and_moving_add_no_history_and_change_no_pixels() { + let mut s = session(); + let before = s.active().unwrap().doc.layers[0].surface().unwrap().pixel(5, 5); + let past = s.active().unwrap().history.past_len(); + s.execute("view.colorSamplers.add", json!({"x": 5, "y": 5})).unwrap(); + s.execute("view.colorSamplers.move", json!({"index": 0, "x": 9, "y": 9})).unwrap(); + s.execute("view.colorSamplers.delete", json!({"index": 0})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), past, "no history steps"); + assert_eq!(s.active().unwrap().doc.layers[0].surface().unwrap().pixel(5, 5), before, "no pixels changed"); + assert!(!s.active().unwrap().is_dirty(), "a clean document stays clean"); + } + + #[test] + fn points_belong_to_their_document() { + let mut s = session(); + s.execute("view.colorSamplers.add", json!({"x": 5, "y": 5})).unwrap(); + s.execute("file.new", json!({"width": 40, "height": 40, "background": "white"})).unwrap(); + assert!(points(&mut s).is_empty(), "the new document has no points"); + s.execute("document.activate", json!({"document": 0})).unwrap(); + assert_eq!(points(&mut s).len(), 1, "the first document kept its point"); + // Closing the second document leaves the first's points alone. + s.execute("file.close", json!({"document": 1})).unwrap(); + assert_eq!(points(&mut s).len(), 1); + // Closing the first discards its points with it. + s.execute("file.close", json!({"document": 0})).unwrap(); + assert!(s.execute("view.colorSamplers.list", json!({})).is_err(), "no document open"); + } + + #[test] + fn a_crop_drops_points_outside_the_new_canvas() { + let mut s = session(); + s.execute("view.colorSamplers.add", json!({"x": 5, "y": 5})).unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let p = points(&mut s); + assert_eq!(p.len(), 1, "the outside point was removed"); + assert_eq!(p[0]["position"], json!([5.0, 5.0])); + assert_eq!(s.take_samplers_removed(), 1, "one removed point reported"); + assert_eq!(s.take_samplers_removed(), 0, "reported once"); + } + + #[test] + fn a_crop_moves_points_with_their_pixels() { + // A crop from a non-zero origin moves the canvas: the point follows its pixel. + let mut s = session(); + s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); + s.execute("image.crop", json!({"x": 40, "y": 30, "width": 20, "height": 20})).unwrap(); + let p = points(&mut s); + assert_eq!(p.len(), 1); + assert_eq!(p[0]["position"], json!([10.0, 10.0]), "moved by (-40, -30)"); + assert_eq!(s.take_samplers_removed(), 0, "nothing removed"); + } + + #[test] + fn a_canvas_size_shift_moves_points() { + // Canvas Size anchored bottom-right shifts the canvas origin by (+20, +20). + let mut s = session(); + s.execute("view.colorSamplers.add", json!({"x": 30, "y": 30})).unwrap(); + s.execute("image.canvasSize", json!({"width": 120, "height": 100, "anchor": "bottomRight"})).unwrap(); + let p = points(&mut s); + assert_eq!(p.len(), 1); + assert_eq!(p[0]["position"], json!([50.0, 50.0]), "moved by the canvas shift"); + } + + #[test] + fn a_right_angle_rotation_moves_points() { + // 90° clockwise about the centre: the doc centre stays put. + let mut s = session(); + s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"})).unwrap(); + let p = points(&mut s); + assert_eq!(p.len(), 1); + let pos = p[0]["position"].as_array().unwrap(); + let (x, y) = (pos[0].as_f64().unwrap(), pos[1].as_f64().unwrap()); + // The new canvas is 80×100, so the centre is (40, 50). + assert!((x - 40.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); + } + + #[test] + fn a_rotated_crop_moves_points() { + // The crop frame is centred on the point, so the point lands at the new canvas centre. + let mut s = session(); + s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); + s.execute("image.crop", json!({"x": 40, "y": 30, "width": 20, "height": 20, "angle": 90})).unwrap(); + let p = points(&mut s); + assert_eq!(p.len(), 1); + assert_eq!(p[0]["position"], json!([10.0, 10.0])); + } + + #[test] + fn bad_params_and_no_document_are_errors_not_panics() { + let mut s = Session::new(); + for id in ["view.colorSamplers.add", "view.colorSamplers.move", "view.colorSamplers.delete", "view.colorSamplers.clear", "view.colorSamplers.list"] { + assert!(s.execute(id, json!({})).is_err(), "{id} without a document"); + } + let mut s = session(); + for (id, p) in [ + ("view.colorSamplers.add", json!({})), + ("view.colorSamplers.add", json!({"x": "a", "y": 1})), + ("view.colorSamplers.add", json!({"x": 1e300, "y": 1})), + ("view.colorSamplers.add", json!({"x": 5, "y": f64::NAN})), + ("view.colorSamplers.add", json!({"x": -1, "y": 5})), + ("view.colorSamplers.add", json!({"x": 5, "y": 1000})), + ("view.colorSamplers.move", json!({"index": 0, "x": 1, "y": 1})), + ("view.colorSamplers.delete", json!({"index": 0})), + ("view.colorSamplers.delete", json!({})), + ("view.colorSamplers.move", json!({"index": 4_294_967_296u64, "x": 1, "y": 1})), + ] { + assert!(matches!(s.execute(id, p.clone()), Err(EngineError::BadParams { .. }) | Err(EngineError::Other(_))), "{id} {p}"); + } + // An off-canvas point is refused and places nothing. + assert!(points(&mut s).is_empty()); + } + + #[test] + fn device_cmyk_matches_the_info_panel_formula() { + assert_eq!(device_cmyk(1.0, 1.0, 1.0), [0, 0, 0, 0]); + assert_eq!(device_cmyk(0.0, 0.0, 0.0), [0, 0, 0, 100]); + assert_eq!(device_cmyk(1.0, 0.0, 0.0), [0, 100, 100, 0]); + assert_eq!(device_cmyk(0.5, 0.5, 0.5), [0, 0, 0, 50]); + } + + #[test] + fn points_work_at_every_depth_and_mode() { + for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 8), ("cmyk", 8), ("lab", 8)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 20, "mode": mode, "depth": depth, "background": "white"})).unwrap(); + let r = s.execute("view.colorSamplers.add", json!({"x": 10, "y": 10})).unwrap(); + let rgb = r["point"]["rgb"].as_array().unwrap(); + assert_eq!(rgb.len(), 3); + assert!(rgb.iter().all(|v| v.as_i64().is_some_and(|n| (0..=255).contains(&n))), "{mode} {depth}: {rgb:?}"); + } + } + + /// The mode and depth only affect the composite the readout samples, not the point model. + #[test] + fn document_helpers_are_not_disturbed() { + let mut d = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + d.layers.push(Layer::raster("Background", d.pixel_format())); + assert_eq!(pixel(&d, 0.0, 0.0), Some([0.0, 0.0])); + assert_eq!(pixel(&d, 3.9, 3.9), Some([3.0, 3.0])); + assert_eq!(pixel(&d, 4.0, 0.0), None); + } +} diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index 2359fd97..949d23e2 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -1,10 +1,12 @@ -//! Image › Analysis and Notes in the shell: the Ruler, Count and Note tools (eyedropper group), -//! their canvas overlays and options bars, Window › Measurement Log and Window › Notes, and the -//! Set Measurement Scale / Select Data Points / Place Scale Marker dialogs. +//! Image › Analysis and Notes in the shell: the Ruler, Count, Note and Color Sampler tools +//! (eyedropper group), their canvas overlays and options bars, Window › Measurement Log and +//! Window › Notes, and the Set Measurement Scale / Select Data Points / Place Scale Marker dialogs. //! //! Every change goes through engine commands (`image.analysis.*`, `count.*`, `notes.*`, -//! `measurementLog.*`); this module only holds view state ([`AnalysisUi`], part of the -//! serialisable UI state so the control channel can read and drive it). +//! `view.colorSamplers.*`, `measurementLog.*`); this module only holds view state ([`AnalysisUi`], +//! part of the serialisable UI state so the control channel can read and drive it). The Color +//! Sampler points themselves live in the engine (`DocState.color_samplers`) so every front end +//! shares them; this module only maps pointer input and draws the numbered markers. use egui::{Align2, Color32, CornerRadius, FontId, Pos2, RichText, Stroke, pos2, vec2}; use photocraft_doc::{Document, Ruler}; @@ -27,6 +29,9 @@ pub enum Drag { Count(usize, usize, [f64; 2]), /// Moving note `index`: grab offset from its position. Note(usize, [f64; 2]), + /// Moving Color Sampler point `index` from where the press landed (a click, not a drag, does + /// nothing; dragging off the canvas removes the point). + Sampler(usize, [f64; 2]), } /// View state of the analysis tools and panels. @@ -225,14 +230,22 @@ fn set_ruler(app: &mut PhotocraftApp, r: Ruler) { let _ = app.run("image.analysis.rulerTool", json!({"start": r.start, "end": r.end, "protractor": r.protractor})); } -/// Pointer input for the Ruler, Count and Note tools. Returns true when consumed. +/// Pointer input for the Ruler, Count, Note and Color Sampler tools. Returns true when consumed. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { let tool = app.active_tool(); - if !matches!(tool, Tool::Ruler | Tool::Count | Tool::Note) { + if !matches!(tool, Tool::Ruler | Tool::Count | Tool::Note | Tool::ColorSampler) { return false; } - let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return true }; + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { + // With no document the Color Sampler does nothing and says so, as the other tools are inert. + if tool == Tool::ColorSampler && matches!(ev, ToolEvent::Down { .. }) { + app.ui.status = tl!("No document open").into(); + app.ui.status_error = false; + } + return true; + }; let tol = tolerance(app); + let samplers: Vec<[f64; 2]> = app.session.active().map(|d| d.color_samplers.clone()).unwrap_or_default(); match (tool, ev) { (Tool::Ruler, ToolEvent::Down { x, y, .. }) => { let p = [x, y]; @@ -320,11 +333,59 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } } } + (Tool::ColorSampler, ToolEvent::Down { x, y, .. }) => { + let p = [x, y]; + match nearest_sampler(&samplers, p, tol) { + // ⌥-click a point removes it. + Some(i) if mods.alt => { + let _ = app.run("view.colorSamplers.delete", json!({"index": i})); + } + // A plain click on a point starts a move (committed on release). + Some(i) => app.ui.analysis.drag = Some(Drag::Sampler(i, p)), + // ⌥-click on empty space does nothing; a plain click inside places a new point. + None if mods.alt => {} + None if (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y) => { + let r = app.run("view.colorSamplers.add", json!({"x": x, "y": y})); + status_on_err(app, r); + } + // A click outside the canvas places nothing, as in Photoshop. + None => {} + } + } + (Tool::ColorSampler, ToolEvent::Up { x, y }) => { + if let Some(Drag::Sampler(i, from)) = app.ui.analysis.drag.take() { + if dist(from, [x, y]) <= 0.5 { + return true; // a click on a point, not a drag: nothing to do + } + // Dropped inside the canvas: move it. Dropped outside: remove just that point. + let inside = (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y); + let r = if inside { + app.run("view.colorSamplers.move", json!({"index": i, "x": x, "y": y})) + } else { + app.run("view.colorSamplers.delete", json!({"index": i})) + }; + status_on_err(app, r); + } + } + (Tool::ColorSampler, ToolEvent::Move { .. }) => {} _ => {} } true } +/// The nearest Color Sampler point within `tol` document pixels, or `None`. +fn nearest_sampler(points: &[[f64; 2]], at: [f64; 2], tol: f64) -> Option { + points.iter().enumerate().filter(|(_, q)| dist(**q, at) <= tol).min_by(|a, b| dist(*a.1, at).total_cmp(&dist(*b.1, at))).map(|(i, _)| i) +} + +/// Show a command error in the status bar without treating the sample-limit message as a fault. +fn status_on_err(app: &mut PhotocraftApp, r: Result) { + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = false; + } +} + fn nearest_marker(d: &Document, at: [f64; 2], tol: f64) -> Option<(usize, usize)> { let mut best: Option<(f64, usize, usize)> = None; for (gi, g) in d.measurement.count_groups.iter().enumerate().filter(|(_, g)| g.visible) { @@ -402,6 +463,23 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } } + // Color Sampler points (#1046): persistent numbered markers, drawn whatever the active tool is. + for (i, p) in app.session.active().map(|d| d.color_samplers.clone()).unwrap_or_default().iter().enumerate() { + let at = xf.to_screen(p[0] as f32, p[1] as f32); + // A small target reticle, double-stroked so it reads on any pixel. + for (w, c) in [(3.0, Color32::from_black_alpha(170)), (1.0, Color32::WHITE)] { + painter.circle_stroke(at, 5.0, Stroke::new(w, c)); + painter.line_segment([at - vec2(8.0, 0.0), at - vec2(6.0, 0.0)], Stroke::new(w, c)); + painter.line_segment([at + vec2(6.0, 0.0), at + vec2(8.0, 0.0)], Stroke::new(w, c)); + painter.line_segment([at - vec2(0.0, 8.0), at - vec2(0.0, 6.0)], Stroke::new(w, c)); + painter.line_segment([at + vec2(0.0, 6.0), at + vec2(0.0, 8.0)], Stroke::new(w, c)); + } + // The number, upper right so several points stay readable when they overlap. + let font = FontId::proportional(11.0); + let anchor = at + vec2(7.0, -7.0); + painter.text(anchor + vec2(1.0, 1.0), Align2::LEFT_BOTTOM, format!("{}", i + 1), font.clone(), Color32::from_black_alpha(180)); + painter.text(anchor, Align2::LEFT_BOTTOM, format!("{}", i + 1), font, Color32::WHITE); + } } // ------------------------------------------------------------------ options bars @@ -413,9 +491,11 @@ fn readout(ui: &mut egui::Ui, label: &str, value: Option) { ui.add_space(6.0); } -/// Options bar of the Ruler, Count and Note tools. Returns true when drawn. +/// Options bar of the Ruler, Count, Note and Color Sampler tools. Returns true when drawn. pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool { - let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return matches!(tool, Tool::Ruler | Tool::Count | Tool::Note) }; + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { + return matches!(tool, Tool::Ruler | Tool::Count | Tool::Note | Tool::ColorSampler); + }; match tool { Tool::Ruler => { let sc = doc.measurement.scale.clone(); @@ -508,6 +588,25 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } true } + Tool::ColorSampler => { + let t = Tokens::get(ui.ctx()); + let n = app.session.active().map_or(0, |d| d.color_samplers.len()); + ui.label( + RichText::new(crate::i18n::fmt( + tl!("{n} of {max} sample points"), + &[("n", &n.to_string()), ("max", &photocraft_engine::sampler_cmds::MAX_SAMPLERS.to_string())], + )) + .color(t.text) + .size(11.0), + ); + crate::widgets::vline(ui, 22.0); + if ui.add_enabled_ui(n > 0, |ui| crate::widgets::secondary_button(ui, tl!("Clear All"), 0.0)).inner.clicked() { + let _ = app.run("view.colorSamplers.clear", json!({})); + } + crate::widgets::vline(ui, 22.0); + ui.label(RichText::new(tl!("Click to place · Alt-click or drag off-canvas to remove")).color(t.text_dim).size(11.0)); + true + } _ => false, } } @@ -1032,4 +1131,94 @@ pub(crate) mod tests { assert_eq!(app.session.analysis.log.last().unwrap().values["source"], "Count Tool"); render(&mut app, &ctx, windows); } + + #[test] + fn color_sampler_tool_places_moves_and_removes_points() { + let (mut app, ctx) = app(); + app.ui.tool = Tool::ColorSampler; + let pts = |app: &PhotocraftApp| app.session.active().unwrap().color_samplers.clone(); + down(&mut app, 20.0, 20.0, egui::Modifiers::NONE); + up(&mut app, 20.0, 20.0); + down(&mut app, 60.0, 40.0, egui::Modifiers::NONE); + up(&mut app, 60.0, 40.0); + assert_eq!(pts(&app), vec![[20.0, 20.0], [60.0, 40.0]]); + // Drag the first point to a new pixel (committed on release). + down(&mut app, 20.0, 20.0, egui::Modifiers::NONE); + mv(&mut app, 30.0, 25.0, egui::Modifiers::NONE); + up(&mut app, 30.0, 25.0); + assert_eq!(pts(&app)[0], [30.0, 25.0]); + // A click on a point, with no drag, leaves it where it is. + down(&mut app, 30.0, 25.0, egui::Modifiers::NONE); + up(&mut app, 30.0, 25.0); + assert_eq!(pts(&app)[0], [30.0, 25.0]); + // ⌥-click removes that one point. + down(&mut app, 60.0, 40.0, egui::Modifiers::ALT); + up(&mut app, 60.0, 40.0); + assert_eq!(pts(&app).len(), 1); + // Dragging a point off the canvas removes it. + down(&mut app, 30.0, 25.0, egui::Modifiers::NONE); + mv(&mut app, -50.0, 25.0, egui::Modifiers::NONE); + up(&mut app, -50.0, 25.0); + assert!(pts(&app).is_empty()); + assert_eq!(app.session.active().unwrap().history.past_len(), 0, "sampling adds no history"); + // The overlay and the options bar render without panicking. + down(&mut app, 10.0, 10.0, egui::Modifiers::NONE); + up(&mut app, 10.0, 10.0); + render(&mut app, &ctx, windows); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 100.0)), + zoom: 1.0, + center: [100.0, 50.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut o = ctx.run_ui(Default::default(), |ui| draw_overlay(&app, ui.painter(), &xf)); + o.textures_delta.clear(); + } + + #[test] + fn color_sampler_readout_and_crop_removal_report() { + let (mut app, _) = app(); + app.ui.tool = Tool::ColorSampler; + down(&mut app, 5.0, 5.0, egui::Modifiers::NONE); + up(&mut app, 5.0, 5.0); + down(&mut app, 150.0, 90.0, egui::Modifiers::NONE); + up(&mut app, 150.0, 90.0); + // The list reports both, numbered, with their position and RGB/CMYK readout. + let l = app.run("view.colorSamplers.list", json!({})).unwrap(); + assert_eq!(l["samplers"].as_array().unwrap().len(), 2); + assert_eq!(l["samplers"][0]["number"], 1); + assert_eq!(l["samplers"][1]["position"], json!([150.0, 90.0])); + assert!(l["samplers"][0]["rgb"].as_array().is_some_and(|a| a.len() == 3)); + // Cropping to the top-left drops the outside point and reports how many went. + app.run("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert_eq!(app.session.active().unwrap().color_samplers, vec![[5.0, 5.0]]); + assert!(app.ui.status.contains("removed"), "{}", app.ui.status); + assert!(!app.ui.status_error); + } + + #[test] + fn color_sampler_is_per_document_and_inert_without_one() { + let (mut app, _) = app(); + app.ui.tool = Tool::ColorSampler; + down(&mut app, 10.0, 10.0, egui::Modifiers::NONE); + up(&mut app, 10.0, 10.0); + // A second document starts empty; the first keeps its own point. + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + assert!(app.session.active().unwrap().color_samplers.is_empty()); + down(&mut app, 20.0, 20.0, egui::Modifiers::NONE); + up(&mut app, 20.0, 20.0); + assert_eq!(app.session.active().unwrap().color_samplers, vec![[20.0, 20.0]]); + app.run("document.activate", json!({"document": 0})).unwrap(); + assert_eq!(app.session.active().unwrap().color_samplers, vec![[10.0, 10.0]]); + // With no document the tool says so (no dialog, no panic) and the commands error. + let mut empty = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + empty.ui.tool = Tool::ColorSampler; + down(&mut empty, 5.0, 5.0, egui::Modifiers::NONE); + assert_eq!(empty.ui.status, "No document open"); + assert!(!empty.ui.status_error); + assert!(empty.run("view.colorSamplers.add", json!({"x": 1, "y": 1})).is_err()); + assert!(empty.run("view.colorSamplers.list", json!({})).is_err()); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a640a9e1..cb5b475b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3339,6 +3339,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: pipette_cursor(ui.ctx(), p) } } + // The Color Sampler: a pipette, or the crosshair for Precise Other Cursors. + Tool::ColorSampler => { + if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { + egui::CursorIcon::Crosshair + } else { + pipette_cursor(ui.ctx(), p) + } + } // Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool. Tool::Move | Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 73e9d6d7..83bdd188 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1757,6 +1757,18 @@ mod tests { assert_eq!(app.ui.tool_options.eyedropper_size, 1); } + /// #1046: the Color Sampler tool is selectable over the control channel and reported as active. + #[test] + fn ui_set_selects_the_color_sampler_tool() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tool": "colorSampler"}))["ok"], true); + assert_eq!(app.ui.tool, Tool::ColorSampler); + assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["tool"], "ColorSampler"); + // The `I` key group cycles through the eyedropper tools, so the sampler is on the same key. + assert_eq!(Tool::ColorSampler.key(), 'I'); + } + /// #1919: the Crop tool's overlay menu over the control channel, reported by `ui.inspect`. #[test] fn ui_set_drives_the_crop_overlay_options() { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d60f0a82..9dd0c2f7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2573,3 +2573,8 @@ cameraData Lens Objektiv Show Original Zobrazit originál F cycles the views; hold X to see the image F přepíná zobrazení; podržením X uvidíte obraz Show rulers Zobrazit pravítka + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Kliknutím umístíte · Alt-kliknutím nebo přetažením mimo plátno odeberete + No document open Není otevřen žádný dokument + {n} of {max} sample points {n} z {max} bodů měření diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5a66da4b..457e1832 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2604,3 +2604,8 @@ cameraData Lens Objektiv Show Original Original anzeigen F cycles the views; hold X to see the image F wechselt die Ansicht; X gedrückt halten zeigt das Bild Show rulers Lineale anzeigen + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Klicken zum Platzieren · Alt-Klick oder Ziehen außerhalb der Arbeitsfläche zum Entfernen + No document open Kein Dokument geöffnet + {n} of {max} sample points {n} von {max} Messpunkten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index cdb89c1a..5cb83933 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3665,3 +3665,7 @@ cameraData Lens Φακός Show Original Εμφάνιση αρχικού F cycles the views; hold X to see the image Το F εναλλάσσει τις προβολές· κρατήστε το X για να δείτε την εικόνα Show rulers Εμφάνιση χαράκων + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Κλικ για τοποθέτηση · Alt-κλικ ή σύρσιμο εκτός καμβά για αφαίρεση + {n} of {max} sample points {n} από {max} σημεία δειγματοληψίας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5bb31c7e..d331384b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2645,3 +2645,8 @@ cameraData Lens Objetivo Show Original Mostrar original F cycles the views; hold X to see the image F alterna las vistas; mantén X para ver la imagen Show rulers Mostrar reglas + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Clic para colocar · Alt-clic o arrastrar fuera del lienzo para quitar + No document open Ningún documento abierto + {n} of {max} sample points {n} de {max} puntos de muestreo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c3b4be8d..20871398 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2982,3 +2982,8 @@ cameraData Lens Objectif Show Original Afficher l'original F cycles the views; hold X to see the image F fait défiler les vues ; maintenez X pour voir l'image Show rulers Afficher les règles + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors du canevas pour supprimer + No document open Aucun document ouvert + {n} of {max} sample points {n} sur {max} points d'échantillonnage diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b5453ff8..248865b8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2969,3 +2969,8 @@ cameraData Lens Lensa Show Original Tampilkan Asli F cycles the views; hold X to see the image F berganti tampilan; tahan X untuk melihat gambar Show rulers Tampilkan Penggaris + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Klik untuk menempatkan · Alt-klik atau seret ke luar kanvas untuk menghapus + No document open Tidak ada dokumen yang terbuka + {n} of {max} sample points {n} dari {max} titik sampel diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 50cc663a..398a11f4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2636,3 +2636,8 @@ cameraData Lens Obiettivo Show Original Mostra originale F cycles the views; hold X to see the image F scorre le viste; tieni premuto X per vedere l'immagine Show rulers Mostra righelli + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Fai clic per posizionare · Alt-clic o trascina fuori dalla tela per rimuovere + No document open Nessun documento aperto + {n} of {max} sample points {n} di {max} punti di campionamento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7916f4a7..b1fcbeea 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3579,3 +3579,8 @@ cameraData Lens レンズ Show Original 元の状態を表示 F cycles the views; hold X to see the image F で表示を切り替え、X を押している間は画像を表示 Show rulers 定規を表示 + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove クリックで配置 · Alt キーを押しながらクリック、またはカンバス外へドラッグで削除 + No document open ドキュメントが開かれていません + {n} of {max} sample points {max} 個中 {n} 個のサンプルポイント diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 423234d9..78c75e4e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3148,3 +3148,8 @@ cameraData Lens 렌즈 Show Original 원본 표시 F cycles the views; hold X to see the image F로 보기 전환, X를 누르고 있으면 이미지 표시 Show rulers 눈금자 표시 + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove 클릭하여 배치 · Alt-클릭 또는 캔버스 밖으로 드래그하여 제거 + No document open 열린 문서 없음 + {n} of {max} sample points {max}개 중 {n}개의 샘플 지점 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ec885830..f883800f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3731,3 +3731,7 @@ cameraData Lens Lens Include in playback Opnemen in afspelen Scale: Schaal: Snap to Origin Uitlijnen op oorsprong + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Klik om te plaatsen · Alt-klik of sleep buiten het canvas om te verwijderen + {n} of {max} sample points {n} van {max} steekproefpunten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 64e6e5b7..a6761717 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3580,3 +3580,8 @@ cameraData Lens Obiektyw Show Original Pokaż oryginał F cycles the views; hold X to see the image F przełącza widoki; przytrzymaj X, aby zobaczyć obraz Show rulers Pokaż linijki + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Kliknij, aby umieścić · Alt-kliknij lub przeciągnij poza obszar roboczy, aby usunąć + No document open Brak otwartego dokumentu + {n} of {max} sample points {n} z {max} punktów próbkowania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1e3d568a..b26081f4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2989,3 +2989,8 @@ cameraData Lens Lente Show Original Mostrar original F cycles the views; hold X to see the image F alterna as visualizações; segure X para ver a imagem Show rulers Mostrar réguas + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Clique para posicionar · Alt-clique ou arraste para fora da tela para remover + No document open Nenhum documento aberto + {n} of {max} sample points {n} de {max} pontos de amostragem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7e0b6288..419c25b7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2911,3 +2911,8 @@ cameraData Lens Объектив Select the crop presets to delete Выберите наборы параметров кадрирования для удаления Enter a name for the crop preset Введите имя набора параметров кадрирования There is no crop size or ratio to save Нет размера или соотношения сторон кадрирования для сохранения + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Щёлкните, чтобы разместить · Alt-щелчок или перетаскивание за пределы холста для удаления + No document open Документ не открыт + {n} of {max} sample points {n} из {max} точек замера diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 141dcd49..16a41e35 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3667,3 +3667,8 @@ cameraData Lens Об'єктив There is no crop size or ratio to save Немає розміру чи співвідношення сторін кадрування для збереження Scale: Масштаб: Snap to Origin Прив'язати до початку координат + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Клацніть, щоб розмістити · Alt-клацання або перетягування за межі полотна для видалення + No document open Документ не відкрито + {n} of {max} sample points {n} з {max} точок вимірювання diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 65117cba..0594427f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2580,3 +2580,8 @@ cameraData Lens 镜头 Select the crop presets to delete 选择要删除的裁剪预设 Enter a name for the crop preset 输入裁剪预设的名称 There is no crop size or ratio to save 没有可保存的裁剪大小或比例 + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove 单击以放置 · Alt 单击或拖到画布外以移除 + No document open 未打开任何文档 + {n} of {max} sample points {max} 个取样点中的 {n} 个 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5a9d8c01..80458d29 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2550,3 +2550,8 @@ cameraData Lens 鏡頭 Select the crop presets to delete 選取要刪除的裁切預設集 Enter a name for the crop preset 輸入裁切預設集的名稱 There is no crop size or ratio to save 沒有可儲存的裁切尺寸或比例 + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove 按一下以放置 · Alt 按一下或拖曳至畫布外以移除 + No document open 未開啟任何文件 + {n} of {max} sample points {max} 個取樣點中的 {n} 個 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index d8709a49..dc0bc0b5 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -104,6 +104,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")), ("rotate-cw", include_bytes!("../../../assets/icons/rotate-cw.svg")), ("ruler", include_bytes!("../../../assets/icons/ruler.svg")), + ("sampler", include_bytes!("../../../assets/icons/sampler.svg")), ("scaling", include_bytes!("../../../assets/icons/scaling.svg")), ("scan", include_bytes!("../../../assets/icons/scan.svg")), ("scan-line", include_bytes!("../../../assets/icons/scan-line.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index ebd020a9..0b3b58dd 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -98,6 +98,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::BackgroundEraser => "eraser-background", Tool::MagicEraser => "eraser-magic", Tool::Eyedropper => "pipette", + Tool::ColorSampler => "sampler", Tool::Ruler => "ruler", Tool::Note => "message-square", Tool::Count => "circle-dot", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index da4c758a..49a0332b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -942,6 +942,13 @@ impl PhotocraftApp { match &r { Ok(_) => { self.sync_views(); + let removed = self.session.take_samplers_removed(); + if removed > 0 { + // A canvas-size change (crop, Canvas Size, Image Size, rotation) dropped Color + // Sampler points that fell outside the new canvas (#1046). + self.ui.status = if removed == 1 { "1 sample point removed".into() } else { format!("{removed} sample points removed") }; + self.ui.status_error = false; + } if self.ui.status_error { self.ui.status.clear(); self.ui.status_error = false; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 49318f84..b893521a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -22,7 +22,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::ObjectSelection, Tool::QuickSelection, Tool::MagicWand], &[Tool::Crop, Tool::PerspectiveCrop, Tool::Slice, Tool::SliceSelect], &[Tool::Frame], - &[Tool::Eyedropper, Tool::Ruler, Tool::Note, Tool::Count], + &[Tool::Eyedropper, Tool::ColorSampler, Tool::Ruler, Tool::Note, Tool::Count], ], &[ &[Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye], @@ -1744,12 +1744,9 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { row(&mut cols[0], "R:", rgba.map_or_else(dash, |c| q(c[0]).to_string())); row(&mut cols[0], "G:", rgba.map_or_else(dash, |c| q(c[1]).to_string())); row(&mut cols[0], "B:", rgba.map_or_else(dash, |c| q(c[2]).to_string())); - // Naive device CMYK (no ICC profile yet), in percent like Photoshop's readout. - let cmyk = rgba.map(|c| { - let k = 1.0 - c[0].max(c[1]).max(c[2]); - let f = |v: f32| if k >= 1.0 { 0.0 } else { (1.0 - v - k) / (1.0 - k) }; - [f(c[0]), f(c[1]), f(c[2]), k].map(|v| (v * 100.0).round() as i32) - }); + // Naive device CMYK (no ICC profile yet), in percent like Photoshop's readout. The Color + // Sampler readout uses the same conversion (`sampler_cmds::device_cmyk`). + let cmyk = rgba.map(|c| photocraft_engine::sampler_cmds::device_cmyk(c[0], c[1], c[2])); for (i, k) in ["C:", "M:", "Y:", "K:"].iter().enumerate() { row(&mut cols[1], k, cmyk.map_or_else(dash, |c| format!("{}%", c[i]))); } @@ -1768,6 +1765,36 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .color(t.text_dim) .size(11.5), ); + // Color Sampler points (#1046): each under its number, with its position in the current ruler + // unit and the same RGB/CMYK readout as the pointer (one composite pixel each). + let samplers = app.session.execute("view.colorSamplers.list", json!({})).ok(); + let samplers = samplers.as_ref().and_then(|v| v["samplers"].as_array()).cloned().unwrap_or_default(); + if !samplers.is_empty() { + widgets::hairline(ui); + let cell = |ui: &mut egui::Ui, s: String| { + ui.label(RichText::new(s).font(mono.clone()).color(t.text)); + }; + for s in &samplers { + let n = s["number"].as_i64().unwrap_or(0); + let at = s["position"] + .as_array() + .map_or((0.0, 0.0), |a| (a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0))); + let num = |v: &Value, i: usize| v.get(i).and_then(Value::as_i64).map_or_else(|| "—".to_string(), |n| n.to_string()); + let (rgb, cmyk) = (&s["rgb"], &s["cmyk"]); + ui.horizontal(|ui| { + ui.add_sized([24.0, 16.0], egui::Label::new(RichText::new(format!("#{n}")).color(t.text_dim).size(11.5))); + cell(ui, format!("X: {} Y: {}", fx(at.0), fy(at.1))); + }); + ui.horizontal(|ui| { + ui.add_sized([24.0, 16.0], egui::Label::new(RichText::new("").size(11.5))); + cell(ui, format!("R:{} G:{} B:{}", num(rgb, 0), num(rgb, 1), num(rgb, 2))); + }); + ui.horizontal(|ui| { + ui.add_sized([24.0, 16.0], egui::Label::new(RichText::new("").size(11.5))); + cell(ui, format!("C:{}% M:{}% Y:{}% K:{}%", num(cmyk, 0), num(cmyk, 1), num(cmyk, 2), num(cmyk, 3))); + }); + } + } } fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { @@ -4002,6 +4029,34 @@ mod color_tests { } } +#[cfg(test)] +mod info_panel_sampler_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + /// #1046: the Info panel lists each Color Sampler point under its number, with its position and + /// the composite RGB/CMYK readout, one composite pixel each. + #[test] + fn info_panel_lists_the_sample_points() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 60, "background": "white"})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 5, "y": 6})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 40, "y": 30})).unwrap(); + let mut h = Harness::builder().with_size(vec2(340.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| info_panel(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(3); + // Both points are listed, numbered, and read as white (RGB 255 / CMYK 0%). + assert!(h.query_by_label("#1").is_some(), "point 1 is listed"); + assert!(h.query_by_label("#2").is_some(), "point 2 is listed"); + assert_eq!(h.query_all_by_label_contains("R:255 G:255 B:255").count(), 2, "each point shows its RGB"); + assert_eq!(h.query_all_by_label_contains("C:0% M:0% Y:0% K:0%").count(), 2, "each point shows its CMYK"); + // Clearing them takes the list away. + h.state_mut().run("view.colorSamplers.clear", json!({})).unwrap(); + h.run_steps(2); + assert!(h.query_by_label("#1").is_none(), "no points listed once cleared"); + } +} + #[cfg(test)] mod history_transform_tests { use super::*; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5432425d..52d44b9b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -78,6 +78,7 @@ pub enum Tool { Crop, PerspectiveCrop, Eyedropper, + ColorSampler, Ruler, Note, Count, @@ -137,7 +138,7 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 66] = [ + pub const ALL: [Tool; 67] = [ Tool::Move, Tool::Artboard, Tool::RectMarquee, @@ -151,6 +152,7 @@ impl Tool { Tool::Crop, Tool::PerspectiveCrop, Tool::Eyedropper, + Tool::ColorSampler, Tool::Ruler, Tool::Note, Tool::Count, @@ -222,6 +224,7 @@ impl Tool { Tool::BackgroundEraser => "Background Eraser Tool", Tool::MagicEraser => "Magic Eraser Tool", Tool::Eyedropper => "Eyedropper Tool", + Tool::ColorSampler => "Color Sampler Tool", Tool::Ruler => "Ruler Tool", Tool::Note => "Note Tool", Tool::Count => "Count Tool", @@ -319,7 +322,7 @@ impl Tool { Tool::RectMarquee | Tool::EllipseMarquee => 'M', Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::ColorReplacement => 'B', Tool::Eraser | Tool::BackgroundEraser | Tool::MagicEraser => 'E', - Tool::Eyedropper | Tool::Ruler | Tool::Note | Tool::Count => 'I', + Tool::Eyedropper | Tool::ColorSampler | Tool::Ruler | Tool::Note | Tool::Count => 'I', Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso => 'L', Tool::MagicWand => 'W', Tool::Crop | Tool::PerspectiveCrop | Tool::Slice | Tool::SliceSelect => 'C', @@ -352,6 +355,7 @@ impl Tool { Tool::MixerBrush => "🖌", Tool::Eraser => "⌫", Tool::Eyedropper => "💧", + Tool::ColorSampler => "◎", Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso => "L", Tool::MagicWand => "W", Tool::Crop => "C", @@ -1305,7 +1309,7 @@ mod tests { assert!(!Tool::RedEye.is_brushlike()); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp)); - assert_eq!(Tool::ALL.len(), 66); + assert_eq!(Tool::ALL.len(), 67); assert_eq!(Tool::from_name("Vertical Type Mask Tool"), Some(Tool::VerticalTypeMask)); assert_eq!(Tool::from_name("addAnchorPoint"), Some(Tool::AddAnchorPoint)); assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint)); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 5d95df62..646af4f7 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -165,7 +165,8 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => { reset!(vector_mode, shape_fill, stroke_width, shape_stroke, corner_radius, polygon_sides, line_weight) } - Tool::Ruler + Tool::ColorSampler + | Tool::Ruler | Tool::Note | Tool::Count | Tool::Brush diff --git a/docs/gaps.md b/docs/gaps.md index e638534c..171d38f3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -54,9 +54,9 @@ what an agent can't finish alone. scripted check there) is the only real oracle. ### G6. Missing tools -- **Missing (5 of 68):** Color Sampler (#1046), Color Replacement (engine command exists), Art +- **Missing (4 of 68):** Color Replacement (engine command exists), Art History Brush, Add Anchor Point, Delete Anchor Point. Convert Point landed (#3204). Freeform - Pen and Curvature Pen landed (#1054); Horizontal and Vertical Type Mask landed (#1055, + Pen and Curvature Pen landed (#1054); Color Sampler landed (#1046, `view.colorSamplers.*`); Horizontal and Vertical Type Mask landed (#1055, `type.createSelection`); Perspective Crop landed (#1043, `image.perspectiveCrop`); Frame landed (#2914). - **Evidence:** `Tool` enum (`crates/ui-egui/src/state.rs`) against the 67 `` entries of diff --git a/docs/scorecard.md b/docs/scorecard.md index 41c48127..ae97ade7 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -163,7 +163,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | | TOOL-214-17 | Rotate View tool: drag to turn the view, Reset View, Match Rotation copies the angle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Tool::RotateView in the Hand flyout; view.rotateView / view.resetView; Match Rotation copies views[].rotation; enhanced_controls.rotate_view_with_trackpad is read | -| TOOL-1649-1 | Eyedropper Sample Size (Point, 3×3 … 101×101 Average), Sample layers, Show Sampling Ring | done | [#1649](https://github.com/storytold/photocraft/issues/1649) | document.sampleColor (sample_cmds.rs): average of the square clipped to the canvas, in the document's model and depth; eyedropper_ui.rs options bar; shared with Alt-click, dialog eyedroppers and the Info panel; ui.set eyedropperSampleSize/eyedropperSample/eyedropperRing. Color Sampler tool still missing | +| TOOL-1649-1 | Eyedropper Sample Size (Point, 3×3 … 101×101 Average), Sample layers, Show Sampling Ring | done | [#1649](https://github.com/storytold/photocraft/issues/1649) | document.sampleColor (sample_cmds.rs): average of the square clipped to the canvas, in the document's model and depth; eyedropper_ui.rs options bar; shared with Alt-click, dialog eyedroppers and the Info panel; ui.set eyedropperSampleSize/eyedropperSample/eyedropperRing. Color Sampler tool is separate (#1046, sampler_cmds.rs) | | TOOL-214-20 | Crop overlay options: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio, Golden Spiral; Auto / Always / Never Show Overlay; O cycles the overlay, Shift+O its orientation | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_overlay.rs: unit-square guides shared by upright and turned frames (canvas.rs), options-bar menu, O / Shift+O ahead of the tool keys in shortcuts.rs; ToolOptions crop_overlay / crop_overlay_show / crop_overlay_orientation, ui.set cropOverlay / cropOverlayShow / cropOverlayOrientation | | TOOL-214-18 | Crop: drag outside the frame to rotate it (Shift 15 deg steps, angle readout); Enter rotates and crops in one step | done | [#1792](https://github.com/storytold/photocraft/issues/1792) | image.crop angle param (image_cmds.rs: Image Rotation > Arbitrary + crop, one undo step); crop_ui.rs turns, moves and resizes the frame along its axes (Classic Mode; default mode turns the image instead, TOOL-214-23); UiState.crop_angle | | TOOL-214-19 | Crop: arrow keys nudge the crop box (Shift 10 px), X swaps portrait/landscape, W x H readout while drawing or resizing | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_ui.rs keys/nudge/swap_orientation (ahead of the selection nudge and tools.swapColors in shortcuts.rs; X also swaps the options-bar ratio) and sizing (canvas.rs draws it with the marquee readout); turned frames included | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7951232f..ea014b09 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -431,7 +431,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Color Sampler and Frame (Perspective Crop landed 2026-10-10, #1043: `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush and Frame (Perspective Crop landed 2026-10-10, #1043: `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6fd9b839..270214e1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -32,7 +32,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Selection | Object Selection, Quick Selection, Magic Wand | | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | -| Eyedropper | Eyedropper, Ruler, Note, Count | Color Sampler (#1046) | +| Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | | Retouch | Remove, Spot Healing, Healing, Patch, Content-Aware Move, Red Eye | | | Brush | Brush, Pencil, Mixer Brush | Color Replacement (`paint.colorReplacement` exists) | | Stamp | Clone Stamp, Pattern Stamp | | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 158c7816..2f993051 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -241,7 +241,7 @@ id = "TOOL-1649-1" target = "Eyedropper Sample Size (Point, 3×3 … 101×101 Average), Sample layers, Show Sampling Ring" status = "done" issue = 1649 -note = "document.sampleColor (sample_cmds.rs): average of the square clipped to the canvas, in the document's model and depth; eyedropper_ui.rs options bar; shared with Alt-click, dialog eyedroppers and the Info panel; ui.set eyedropperSampleSize/eyedropperSample/eyedropperRing. Color Sampler tool still missing" +note = "document.sampleColor (sample_cmds.rs): average of the square clipped to the canvas, in the document's model and depth; eyedropper_ui.rs options bar; shared with Alt-click, dialog eyedroppers and the Info panel; ui.set eyedropperSampleSize/eyedropperSample/eyedropperRing. Color Sampler tool is separate (#1046, sampler_cmds.rs)" [[item]] id = "TOOL-214-20"