Eyedropper: the comparison ring while sampling (#213) (#1227)

* Eyedropper: the comparison ring while sampling (#213)

The colour under the pointer on the upper arc, the current foreground below,
over a thin dark outline, drawn at the cursor for the Eyedropper and for
Alt-sampling with a painting tool. The Precise-cursor preference keeps the
plain crosshair. Scorecard row flipped to done.

* Eyedropper ring: only while the button is held, theme outline

Photoshop shows the comparison ring only while sampling (button down), not
on hover; hovering now draws the plain crosshair and samples nothing, so
there is no per-frame document.pixel call when idle. The outline uses the
theme's shadow token instead of a hard-coded colour.

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

* Eyedropper cursor: ring first, then main's pipette or Precise crosshair

After rebasing onto main (#1028's pipette cursor), the Eyedropper had two
match arms (the second unreachable) and Precise Other Cursors hid the
comparison ring. One arm now shows the ring while sampling, otherwise the
pipette, or the crosshair when Other Cursors is Precise.

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

* rustfmt canvas.rs

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-08 19:10:08 -04:00
1 parent 3439d327f7
commit ef9d075f23
2 files changed
+83 -7

No files matched your search

+81 -5
View File
@@ -329,6 +329,53 @@ fn brush_tip_centre(tool: Tool, option: bool, show_crosshair: bool, radius: f32)
show_crosshair || radius > 6.0 || tool == Tool::BackgroundEraser
}
/// The display colour of an RGB triple (0..1 per channel) as an egui colour.
fn display_color(c: [f32; 3]) -> Color32 {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
}
/// The Eyedropper ring's two colours at document point (x, y): the colour under the pointer
/// (what a click would pick) and the current foreground, or `None` where there is no colour
/// to sample (#213).
pub(crate) fn eyedropper_ring_colors(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<(Color32, Color32)> {
let new = composite_color(app, x, y)?;
let fg = app.session.tools.foreground;
Some((display_color(new), display_color([fg[0], fg[1], fg[2]])))
}
/// Draw the Eyedropper's comparison ring at `p`: the sampled colour on the upper arc, the
/// current one below, both over a thin dark outline so they read on any image (#213). Like
/// Photoshop it shows only while the mouse button is held (`held`): hovering shows the plain
/// crosshair and samples nothing. `None` when not held, there is nothing to sample, or the
/// Precise-cursor preference wants the plain crosshair.
fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, p: Pos2, held: bool) -> Option<egui::CursorIcon> {
if !held || app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
return None;
}
let [x, y] = xf.to_doc(p);
let (new, current) = eyedropper_ring_colors(app, x, y)?;
// Two arcs with a small gap at 3 and 9 o'clock. Screen y grows down, so PI..TAU is the
// upper half (the new colour) and 0..PI the lower (the current one).
use std::f32::consts::{PI, TAU};
let (r, w, gap) = (11.0_f32, 4.0_f32, 0.16_f32);
let arc = |a0: f32, a1: f32| -> Vec<Pos2> {
(0..=24)
.map(|i| {
let a = a0 + (a1 - a0) * i as f32 / 24.0;
p + vec2(a.cos(), a.sin()) * r
})
.collect()
};
let outline = crate::theme::Tokens::get(painter.ctx()).shadow;
for (a0, a1, colour) in [(PI + gap, TAU - gap, new), (gap, PI - gap, current)] {
let points = arc(a0, a1);
painter.add(egui::Shape::line(points.clone(), Stroke::new(w + 1.5, outline)));
painter.add(egui::Shape::line(points, Stroke::new(w, colour)));
}
Some(egui::CursorIcon::None)
}
fn begin_live_stroke(app: &PhotocraftApp) -> Option<LiveStroke> {
static STROKES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let st = app.session.active()?;
@@ -2218,7 +2265,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// ⌥ turns a painting tool into the Eyedropper (`alt_eyedropper`): its cursor too,
// unless Preferences › Cursors › Other Cursors asks for the precise crosshair.
t if app.alt_sampling || (app.drag.is_none() && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => {
if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
// While it samples (button held), the comparison ring replaces the cursor (#213).
if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) {
icon
} else if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
egui::CursorIcon::Crosshair
} else {
pipette_cursor(ui.ctx(), p)
@@ -2272,10 +2322,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
}
}
// The Eyedropper: the comparison ring follows the pointer while it samples (#213);
// otherwise its pipette, or the crosshair for Precise Other Cursors.
Tool::Eyedropper => {
if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) {
icon
} else 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::Eyedropper
if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise =>
{
Tool::Move | Tool::Type | Tool::VerticalType if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => {
egui::CursorIcon::Crosshair
}
Tool::Move => egui::CursorIcon::Move,
@@ -2294,7 +2353,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
}
Tool::Type | Tool::VerticalType => egui::CursorIcon::Text,
Tool::Eyedropper => pipette_cursor(ui.ctx(), p),
Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, view.zoom),
_ => egui::CursorIcon::Crosshair,
};
@@ -3457,6 +3515,24 @@ mod tests {
}
}
#[test]
fn eyedropper_ring_colors_pair_the_sampled_and_current_colours() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
app.run("shape.create", json!({"kind": "rect", "rect": [0, 0, 20, 20], "fill": "#ff0000"})).unwrap();
app.run("tools.setColors", json!({"foreground": [0.0, 0.2, 1.0, 1.0]})).unwrap();
let (new, current) = super::eyedropper_ring_colors(&mut app, 10.0, 10.0).expect("over the red square");
let near = |c: egui::Color32, r: u8, g: u8, b: u8| {
let d = |a: u8, b: u8| i32::from(a).abs_diff(i32::from(b));
d(c.r(), r) <= 1 && d(c.g(), g) <= 1 && d(c.b(), b) <= 1
};
assert!(near(new, 255, 0, 0), "{new:?}");
assert!(near(current, 0, 51, 255), "{current:?}");
// Transparency and points outside the canvas have nothing to compare.
assert!(super::eyedropper_ring_colors(&mut app, 30.0, 10.0).is_none());
assert!(super::eyedropper_ring_colors(&mut app, -1.0, 10.0).is_none());
}
#[test]
fn eyedropper_drag_updates_the_sampled_colour() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
+2 -2
View File
@@ -64,9 +64,9 @@ note = "Clone Source overlay drawn with opacity, inversion, circular clipping an
[[item]]
id = "TOOL-213-9"
target = "Eyedropper comparison ring (new colour above, current below) while sampling"
status = "missing"
status = "done"
issue = 213
note = "sample_eyedropper (canvas.rs:1552) draws no ring"
note = "eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair"
[[item]]
id = "TOOL-213-10"