Merge PR #1979: LR-EDIT-COLOR-GRADING: a wheel drag holds the pointer and pu

# Conflicts:
#	crates/ui-egui/src/lib.rs
This commit is contained in:
francoisgn
2026-10-22 22:27:59 +03:00
5 changed files with 231 additions and 5 deletions
+4 -2
View File
@@ -241,7 +241,8 @@ pub struct Headless {
quit: bool,
/// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host).
pub window_maximized: bool,
/// Window-management commands the app sent (`StartDrag`, `Maximized`, …), oldest first.
/// Window-management and pointer commands the app sent (`StartDrag`, `Maximized`,
/// `CursorPosition`, …), oldest first.
pub window_commands: Vec<ViewportCommand>,
/// The pointer cursor the last frame asked for (what a real host would show).
pub last_cursor: egui::CursorIcon,
@@ -342,7 +343,8 @@ impl Headless {
self.window_maximized = on;
self.window_commands.push(c);
}
ViewportCommand::StartDrag => self.window_commands.push(c),
// recorded, not carried out: the simulated pointer moves only when a test moves it
ViewportCommand::StartDrag | ViewportCommand::CursorPosition(_) | ViewportCommand::CursorGrab(_) => self.window_commands.push(c),
_ => {}
}
}
+1
View File
@@ -1416,6 +1416,7 @@ impl LightcraftApp {
} else {
frame_rate::forget(ctx);
}
panels::edit::wheel_pointer_frame_end(ctx);
ctx.output(|o| {
for c in &o.commands {
if let egui::OutputCommand::CopyText(text) = c {
+103 -2
View File
@@ -1552,7 +1552,8 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings
// puck as it is at that moment.
let grab_id = egui::Id::new(("wheelGrab", key));
let mods = ui.input(|i| WheelMods::from_modifiers(i.modifiers));
let grab = match resp.interact_pointer_pos() {
let pointer = wheel_pointer(ui, &resp);
let grab = match pointer {
Some(q) if resp.dragged() || resp.clicked() => {
let held = ui.data(|m| m.get_temp::<WheelGrab>(grab_id)).filter(|g| g.mods == mods && !resp.drag_started());
Some(held.unwrap_or_else(|| {
@@ -1591,7 +1592,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings
let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)}));
}
if (resp.dragged() || resp.clicked())
&& let Some(q) = resp.interact_pointer_pos()
&& let Some(q) = pointer
{
let (hue, sat) = grab.map_or_else(|| wheel_pick(q - c, rad, None), |g| g.pick(q - c, rad));
let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}}));
@@ -1599,6 +1600,13 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings
if resp.drag_stopped() || resp.clicked() {
let _ = app.run("develop.endInteraction", json!({}));
}
// released, or Esc (egui ends a drag on it)
if resp.drag_stopped()
&& let Some(held) = ui.ctx().data_mut(|m| m.remove_temp::<WheelCapture>(egui::Id::new(WHEEL_CAPTURE)))
&& !held.dropped
{
give_pointer_back(ui.ctx(), held.origin);
}
if resp.double_clicked() {
let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}}));
}
@@ -1702,6 +1710,99 @@ impl WheelGrab {
}
}
/// A wheel drag holds the pointer, as in Lightroom (issue #1899): it is hidden for the drag, the
/// puck follows the mouse's own travel, and on release the pointer is back where the button went
/// down, so a long (Alt) drag doesn't leave it far from the wheels. On macOS the pointer is locked
/// in place for the drag; elsewhere it moves unseen and is put back. The browser can't move it
/// (eframe's web host ignores the command), so there it only shows again where the drag ended.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct WheelCapture {
/// Where the button went down: the pointer goes back there.
origin: Pos2,
/// Where the pointer would be had it been free: where the drag began plus the travel since.
pos: Pos2,
/// Let go early (the window lost focus): the rest of this drag leaves the wheel alone.
dropped: bool,
/// The pass that last drew the dragged wheel.
pass: u64,
}
/// One wheel is dragged at a time, so one capture for them all.
const WHEEL_CAPTURE: &str = "wheelCapture";
/// The mouse's travel this frame: the pointer's, or, while the pointer stands still (locked, or
/// against a screen edge), the raw motion the host reports.
fn wheel_travel(delta: egui::Vec2, motion: Option<egui::Vec2>) -> egui::Vec2 {
if delta != egui::Vec2::ZERO {
return delta;
}
motion.filter(|m| m.x.is_finite() && m.y.is_finite()).unwrap_or(egui::Vec2::ZERO)
}
/// Where the wheel `resp` reads the pointer this frame: where it clicked, or for a drag the held
/// pointer ([`WheelCapture`]), `None` once a lost focus let go of it (Esc ends the drag itself:
/// egui stops a drag on Esc).
fn wheel_pointer(ui: &egui::Ui, resp: &egui::Response) -> Option<Pos2> {
let q = resp.interact_pointer_pos()?;
if !resp.dragged() {
return Some(q);
}
let ctx = ui.ctx();
let id = egui::Id::new(WHEEL_CAPTURE);
let held = ctx.data(|m| m.get_temp::<WheelCapture>(id)).filter(|_| !resp.drag_started());
let mut cap = held.unwrap_or_else(|| {
if cfg!(target_os = "macos") {
ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked));
}
WheelCapture { origin: ui.input(|i| i.pointer.press_origin()).unwrap_or(q), pos: q, ..Default::default() }
});
cap.pass = ctx.cumulative_pass_nr();
if !cap.dropped {
let (travel, unfocused) = ui.input(|i| {
// the event, not `focused`: a click that activates the window may land before its focus
let unfocused = i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false)));
(wheel_travel(i.pointer.delta(), i.pointer.motion()), unfocused)
});
if !resp.drag_started() {
cap.pos += travel;
}
if unfocused {
give_pointer_back(ctx, cap.origin);
cap.dropped = true;
} else {
ctx.set_cursor_icon(egui::CursorIcon::None);
}
}
ctx.data_mut(|m| m.insert_temp(id, cap));
(!cap.dropped).then_some(cap.pos)
}
/// Unlock and show the pointer a wheel drag held, back at `origin`.
fn give_pointer_back(ctx: &egui::Context, origin: Pos2) {
if cfg!(target_os = "macos") {
ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::None));
}
if cfg!(not(target_arch = "wasm32")) {
ctx.send_viewport_cmd(egui::ViewportCommand::CursorPosition(origin));
}
}
/// Once a frame, after the panels: a held pointer stays hidden over whatever set its own cursor,
/// and is given back if no wheel drew this pass (the flyout closed or the view changed under the
/// drag), so it is never left locked or hidden.
pub(crate) fn wheel_pointer_frame_end(ctx: &egui::Context) {
let id = egui::Id::new(WHEEL_CAPTURE);
let Some(cap) = ctx.data(|m| m.get_temp::<WheelCapture>(id)) else { return };
if cap.pass != ctx.cumulative_pass_nr() {
ctx.data_mut(|m| m.remove::<WheelCapture>(id));
if !cap.dropped {
give_pointer_back(ctx, cap.origin);
}
} else if !cap.dropped {
ctx.set_cursor_icon(egui::CursorIcon::None);
}
}
/// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre.
/// `lock`: hold the hue (the saturation is `v` projected onto that hue's spoke) or hold the
/// saturation (the hue is the angle of `v`). A non-finite `v` gives a finite answer (the locked
+122
View File
@@ -375,3 +375,125 @@ fn wheel_rim_is_fully_saturated_and_the_centre_neutral() {
let (hi, lo) = (p.r().max(p.g()).max(p.b()), p.r().min(p.g()).min(p.b()));
assert!(hi - lo <= 40 && lo >= 100, "centre is not a neutral grey: {p:?}");
}
// ------------------------------------------------------------------ the held pointer (issue #1899)
fn pointer_event(h: &mut Headless, e: egui::Event) {
h.events.push(e);
h.step();
}
fn button(h: &mut Headless, at: egui::Pos2, pressed: bool) {
let e = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
pointer_event(h, e);
}
/// Press the midtones wheel at `at` and move the pointer 10 to the right, far enough to make it
/// a drag; where the pointer is then.
fn start_wheel_drag(h: &mut Headless, at: egui::Pos2) -> egui::Pos2 {
pointer_event(h, egui::Event::PointerMoved(at));
button(h, at, true);
let to = at + egui::vec2(10.0, 0.0);
pointer_event(h, egui::Event::PointerMoved(to));
h.step();
to
}
fn cursor_moves(h: &Headless) -> Vec<egui::Pos2> {
h.window_commands
.iter()
.filter_map(|c| match c {
egui::ViewportCommand::CursorPosition(p) => Some(*p),
_ => None,
})
.collect()
}
#[test]
fn a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits() {
use crate::panels::edit::wheel_pick;
let mut h = demo();
let rad = wheel_radius(&h, "midtones");
set_wheel(&mut h, "midtones", 0.0, 0.0);
let c = widget(&h, "wheel:midtones").unwrap().center();
let held = start_wheel_drag(&mut h, c + egui::vec2(rad * 0.4, 0.0));
// the pointer stays where it is (macOS locks it there) while the mouse goes on moving up
for _ in 0..5 {
pointer_event(&mut h, egui::Event::MouseMoved(egui::vec2(0.0, -rad * 0.1)));
}
let (hue, sat) = wheel_value(&h, "midtones");
let (want_hue, want_sat) = wheel_pick(held + egui::vec2(0.0, -rad * 0.5) - c, rad, None);
assert!((hue - want_hue.round() as f64).abs() <= 1.0 && (sat - want_sat.round() as f64).abs() <= 1.0, "{hue} {sat} vs {want_hue} {want_sat}");
assert!(hue > 30.0, "the puck went up with the mouse: {hue}");
button(&mut h, held, false);
}
#[test]
fn a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed() {
let mut h = demo();
let rad = wheel_radius(&h, "midtones");
set_wheel(&mut h, "midtones", 0.0, 0.0);
let press = widget(&h, "wheel:midtones").unwrap().center() + egui::vec2(rad * 0.3, rad * 0.2);
let at = start_wheel_drag(&mut h, press);
let far = at + egui::vec2(-rad * 0.6, -rad * 0.9);
pointer_event(&mut h, egui::Event::PointerMoved(far));
assert_eq!(h.last_cursor, egui::CursorIcon::None, "hidden while the wheel is dragged");
assert!(cursor_moves(&h).is_empty(), "nothing moves the pointer before the release");
button(&mut h, far, false);
h.step();
assert_eq!(cursor_moves(&h), [press], "back where the button went down");
assert_ne!(h.last_cursor, egui::CursorIcon::None, "shown again");
#[cfg(target_os = "macos")]
assert_eq!(
h.window_commands.iter().filter(|c| matches!(c, egui::ViewportCommand::CursorGrab(_))).collect::<Vec<_>>(),
[&egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked), &egui::ViewportCommand::CursorGrab(egui::CursorGrab::None)],
"locked for the drag, unlocked at the end"
);
// a click is not a drag: the pointer stays visible where it is
h.window_commands.clear();
let r = h.request("ui.clickWidget", json!({"id": "wheel:midtones"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.window_commands.is_empty(), "{:?}", h.window_commands);
}
#[test]
fn escape_or_losing_focus_gives_the_pointer_back_mid_drag() {
let esc = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
for let_go in [esc, egui::Event::WindowFocused(false)] {
let mut h = demo();
let rad = wheel_radius(&h, "midtones");
set_wheel(&mut h, "midtones", 0.0, 0.0);
let press = widget(&h, "wheel:midtones").unwrap().center() + egui::vec2(rad * 0.3, 0.0);
let at = start_wheel_drag(&mut h, press);
assert_eq!(h.last_cursor, egui::CursorIcon::None, "{let_go:?}: hidden while dragged");
pointer_event(&mut h, let_go.clone());
h.step();
assert_ne!(h.last_cursor, egui::CursorIcon::None, "{let_go:?}: shown again");
assert_eq!(cursor_moves(&h), [press], "{let_go:?}: back where it was pressed");
// the rest of the drag no longer moves the puck, and the pointer stays visible
let before = wheel_value(&h, "midtones");
pointer_event(&mut h, egui::Event::PointerMoved(at + egui::vec2(-rad * 0.5, -rad * 0.5)));
assert_eq!(wheel_value(&h, "midtones"), before, "{let_go:?}");
assert_ne!(h.last_cursor, egui::CursorIcon::None, "{let_go:?}");
button(&mut h, at, false);
h.step();
assert_eq!(cursor_moves(&h), [press], "{let_go:?}: given back once");
}
}
#[test]
fn a_wheel_that_goes_away_mid_drag_gives_the_pointer_back() {
let mut h = demo();
let rad = wheel_radius(&h, "midtones");
let press = widget(&h, "wheel:midtones").unwrap().center() + egui::vec2(rad * 0.3, 0.0);
start_wheel_drag(&mut h, press);
assert_eq!(h.last_cursor, egui::CursorIcon::None);
// the flyout closes under the drag: no wheel is drawn to let go of the pointer itself
h.app.ui.toggle_flyout("grading");
h.step();
h.step();
assert!(widget(&h, "wheel:midtones").is_none());
assert_ne!(h.last_cursor, egui::CursorIcon::None);
assert_eq!(cursor_moves(&h), [press]);
}
+1 -1
View File
@@ -198,7 +198,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode |
| LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) |
| LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | |
| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet |
| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); a wheel drag holds the pointer as in Lightroom: hidden while dragging (the puck follows the mouse's travel; on macOS the pointer is locked in place), back where the button went down on release, and given back at once on Esc, a lost window focus or a wheel that goes away mid-drag; the browser can't move the pointer, so there it only shows again where the drag ended (issue #1899; tests `a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits`, `a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed`, `escape_or_losing_focus_gives_the_pointer_back_mid_drag`, `a_wheel_that_goes_away_mid_drag_gives_the_pointer_back`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet |
| LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | |
| LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | |
| LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | |