Color Sampler tool (#1046) (#2753)

* Color Sampler tool (#1046)

Add the Color Sampler to the Eyedropper (I) flyout: click to pin numbered
sample points to canvas pixels, drag to move, Alt-click (or drag off-canvas)
to remove, Clear All in the options bar, at most 10 per document. Each point
reads one composite pixel (RGB 0-255 and naive device CMYK, same as the
pointer readout) and the Info panel lists them under their number with the
position in the ruler unit; the readouts refresh when the document changes.

Points are per-document engine view state (DocState.color_samplers): they
belong to their document, are discarded on close, and are not saved to
.pcraft or PSD. They are driven by commands shared by the UI, CLI, control
channel and MCP (view.colorSamplers.add/.move/.delete/.clear/.list); the
mutations change no pixels and add no History step.

Points follow the canvas geometry (crop, Canvas Size, Image Size, Image
rotation/flip, rotated crop) through a pending Session.sampler_map that
Session::edit applies before culling the points that land outside the new
canvas; the count removed is reported in the status bar.

- engine: new sampler_cmds.rs (commands + shared readout helpers) and the
  sampler_map wiring in lib.rs, image_cmds.rs, mode_cmds.rs, commands.rs,
  analysis_cmds.rs (compound rolls points back on error).
- ui-egui: Tool::ColorSampler + original sampler.svg icon, toolbar slot,
  Info panel list, options bar, pointer handling, overlay markers, cursor.
- docs/scorecard.md regenerated (TOOL-1649-1 no longer says the tool is
  missing). No menu item, so parity is unchanged (627/627).

* Color Sampler follow-up: tool count, README, i18n, roadmap

The full ui-egui suite (not just the filtered tests) surfaced these gaps:

- state.rs: Tool::ALL.len() assertion 52 -> 53.
- README.md: "52 tools" -> "53 tools" and add Color Sampler to the list,
  so readme_tool_list_matches_tool_all passes.
- i18n: the options-bar hint, "{n} of {max} sample points" and
  "No document open" need entries in every catalog that claims complete
  menus (every_tl_literal_is_translated); added to all 16 (coverage
  1584/1584). The translations were drafted alongside the tool.
- docs/roadmap.md: drop Color Sampler from the missing-tools list and note
  it landed.

* Color Sampler: fit main's new tools and Image Size resample type

Reset Tool covers ColorSampler, the test ViewXform sets aspect, and
Image Size checks Method::None instead of an Option.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 11:43:09 -04:00
1 parent d22a9fac6e
commit 5025fd2895
39 files changed
+919 -30

No files matched your search

+2 -1
View File
@@ -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) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> 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 |
+2 -2
View File
@@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 66 tools</h4>
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
<h4>🧰 67 tools</h4>
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
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="1.4" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>

After

Width:  |  Height:  |  Size: 326 B

+4
View File
@@ -175,6 +175,9 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> {
pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
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<R>(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)
+5
View File
@@ -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<Value> {
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<CommandSpec> {
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());
+10
View File
@@ -179,6 +179,11 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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() }))
}
+47
View File
@@ -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<float_cmds::Floating>,
/// 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<fn(&str, &serde_json::Value) -> 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<photocraft_geom::Affine>,
/// 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<R>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option<LayerId>) -> Result<R>) -> Result<R> {
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();
+9
View File
@@ -68,6 +68,15 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
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);
+442
View File
@@ -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<String>) -> 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::<Vec<_>>(),
})
}
fn coord(cmd: &str, p: &Value, key: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number (document pixels)")))
}
/// 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<usize> {
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<R>(s: &mut Session, f: impl FnOnce(&mut DocState) -> Result<R>) -> Result<R> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
let st = s.active().ok_or(EngineError::NoDocument)?;
Ok(list_json(&st.doc, &st.color_samplers))
}
pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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);
}
}
+199 -10
View File
@@ -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<usize> {
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<Value, String>) {
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<f64>) {
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());
}
}
+8
View File
@@ -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 =>
+12
View File
@@ -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() {
+5
View File
@@ -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í
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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} σημεία δειγματοληψίας
Can't render this file because it contains an unexpected character in line 991 and column 18.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2574 and column 53.
+5
View File
@@ -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} 個のサンプルポイント
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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}개의 샘플 지점
Can't render this file because it contains an unexpected character in line 3084 and column 53.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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} точек замера
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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} точок вимірювання
Can't render this file because it contains an unexpected character in line 1348 and column 18.
+5
View File
@@ -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} 个
Can't render this file because it contains an unexpected character in line 2516 and column 53.
+5
View File
@@ -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} 個
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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")),
+1
View File
@@ -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",
+7
View File
@@ -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;
+62 -7
View File
@@ -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::*;
+7 -3
View File
@@ -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));
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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 `<tool>` entries of
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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. |
+1 -1
View File
@@ -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 | |
+1 -1
View File
@@ -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"