diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 7dda3ab54..b465c544e 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -931,12 +931,8 @@ fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histo /// The four shape controls of the parametric curve, in graph order: region 0 is the darkest. pub(crate) const PARAM_AMOUNTS: [&str; 4] = ["curve.shadows", "curve.darks", "curve.lights", "curve.highlights"]; -/// The three region boundaries between those four regions, in graph order. -pub(crate) const PARAM_SPLITS: [&str; 3] = ["curve.splitShadows", "curve.splitMid", "curve.splitHighlights"]; /// Resolution the parametric curve is sampled at, matching `lightcraft_pipeline::finish`'s table. const PARAM_LUT: usize = 1024; -/// Radius (screen points) within which the pointer counts as being on a curve handle. -pub(crate) const CURVE_HIT: f32 = 8.0; /// Segments the graph's curve line is drawn with. pub(crate) const CURVE_SAMPLES: i32 = 96; @@ -1028,56 +1024,52 @@ impl ParametricCurve { 0.5 + 0.5 * (t * std::f64::consts::PI).cos() } - /// The level region `region`'s bump puts the curve at when its slider holds `amount` — where the - /// region's handle is drawn. This is the region's *own* bump (the shape Lightroom draws a handle - /// on), not the monotone curve through it: the bump is linear in the amount and one-to-one, so a - /// drag inverts it exactly and the handle stays under the pointer. - pub(crate) fn handle_level(&self, region: usize, amount: f64) -> f64 { - let x = self.region_x(region); - let bump = amount / 100.0 * 0.22 * self.region_window_at(region, x) * 4.0 * x * (1.0 - x); - (x + bump).clamp(0.0, 1.0) + /// The region that owns graph position `x`: the one whose own window contains it. The four + /// windows tile 0..1, so this is always defined and a drag anywhere on the curve moves exactly + /// one slider — the region under the pointer. + pub(crate) fn region_at(&self, x: f64) -> usize { + let x = x.clamp(0.0, 1.0); + // the bands are black..split0, split0..split1, split1..split2, split2..white + let (s0, s1, s2) = (self.split_x(0), self.split_x(1), self.split_x(2)); + if x <= s0 { + 0 + } else if x <= s1 { + 1 + } else if x <= s2 { + 2 + } else { + 3 + } } - /// The amount that puts region `region`'s handle at `level` — the inverse of [`Self::handle_level`]. - /// A drag that cannot reach the level asked for saturates at ±100 rather than jumping. - pub(crate) fn amount_at_level(&self, region: usize, level: f64) -> f64 { - let x = self.region_x(region); - let per_100 = 0.22 * self.region_window_at(region, x) * 4.0 * x * (1.0 - x); - if per_100.abs() <= f64::EPSILON { + /// How much output level region `region`'s bump gains at `x` per unit of its slider: the bump is + /// linear in the amount, so this is a constant for a given `x`. A drag converts the pointer's + /// vertical motion into a slider change through it. + pub(crate) fn level_per_unit(&self, region: usize, x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + 0.22 / 100.0 * self.region_window_at(region, x) * 4.0 * x * (1.0 - x) + } + + /// The curve level region `region` sits at when its slider holds `amount`, at graph position `x` + /// — where the curve is at the point a drag grabbed, which is what that drag starts from. + pub(crate) fn handle_level_at(&self, region: usize, amount: f64, x: f64) -> f64 { + (x.clamp(0.0, 1.0) + amount * self.level_per_unit(region, x)).clamp(0.0, 1.0) + } + + /// The amount that puts region `region`'s curve at `level` at `x` — the inverse of + /// [`Self::handle_level_at`]. A level the region cannot reach saturates rather than jumping. + pub(crate) fn amount_at_level(&self, region: usize, level: f64, x: f64) -> f64 { + let per_unit = self.level_per_unit(region, x); + if per_unit.abs() <= f64::EPSILON { return 0.0; } - (((level.clamp(0.0, 1.0) - x) / per_100) * 100.0).clamp(-100.0, 100.0).round() + ((level.clamp(0.0, 1.0) - x.clamp(0.0, 1.0)) / per_unit).clamp(-100.0, 100.0).round() } - /// Where split `split` (0..3) sits on the input axis. + /// The graph position split `split` (0..3) sits at, 0..1. pub(crate) fn split_x(&self, split: usize) -> f64 { self.splits.get(split).copied().unwrap_or(0.5) } - - /// The graph position split `split` may be dragged to: inside its own window, a step away from - /// the splits either side of it so the four regions keep a width. - pub(crate) fn split_x_limited(&self, split: usize, x: f64) -> f64 { - let lo = if split == 0 { 0.0 } else { self.split_x(split - 1) + SPLIT_MIN_GAP }; - let hi = if split + 1 >= PARAM_SPLITS.len() { 1.0 } else { self.split_x(split + 1) - SPLIT_MIN_GAP }; - if lo > hi { (lo + hi) / 2.0 } else { x.clamp(lo, hi) } - } - - /// The value split `split`'s slider takes when its handle is dragged to graph position `x`: the - /// position read out in that slider's own range, the way its track reads. - pub(crate) fn split_value(&self, split: usize, x: f64) -> f64 { - let x = self.split_x_limited(split, x); - let id = PARAM_SPLITS.get(split).copied().unwrap_or(PARAM_SPLITS[0]); - controls::find(id).map_or(x * 100.0, |s| (s.min + x * (s.max - s.min)).round().clamp(s.min, s.max)) - } - - /// Screen position of a handle's own centre, the level a drag starts from. - fn handle_screen_start(&self, h: ParamHandle, r: Rect) -> Pos2 { - let (x, y) = match h { - ParamHandle::Region(region) => (self.region_x(region), self.handle_level(region, self.amounts[region])), - ParamHandle::Split(split) => (self.split_x(split), 0.0), - }; - pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()) - } } /// Close a graph drag's `develop` interaction, as one undo step. @@ -1095,136 +1087,86 @@ fn commit_interaction(app: &mut LightcraftApp, ui: &egui::Ui, dragging: bool, st true } -/// One graph drag of the parametric curve: begin the interaction, write the handle's slider on every -/// frame of the drag, end it on release. Every write goes through `develop.set`, so the slider under -/// the graph shows the value the handle holds — and the whole drag is one engine interaction, which -/// is one undo step (Ctrl+Z takes the gesture back, not the last frame of it). -fn param_handles(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::Response, d: &DevelopSettings) { +/// How close (screen points) to the curve a press counts as grabbing it. +const PARAM_GRAB: f32 = 14.0; + +/// The parametric curve's own drag: the graph shows the curve and nothing else, and dragging the +/// line bends the region the pointer is over, with that region's slider following on the same frame. +/// +/// The whole gesture runs inside one `develop` interaction, so it is one undo step (Ctrl+Z takes the +/// gesture back, not the last frame of it), and every write goes through `develop.set`, so the +/// slider, the curve and what is drawn are the same state rather than three things kept in step. +fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::Response, d: &DevelopSettings) { let t = Tokens::get(ui.ctx()); let model = ParametricCurve::new(&d.curve); let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); - let drag_id = egui::Id::new("curve-param-handle-drag"); - let mut dragged: Option = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); - let hovered = if dragged.is_none() { resp.hover_pos().and_then(|q| param_handle_at(&model, r, q)) } else { None }; + let from_screen_x = |q: Pos2| ((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64; + // the graph x of a point, and how far it is from the drawn curve (in screen points) + let near_curve = |q: Pos2| { + let x = from_screen_x(q); + to_screen(x, model.at(x)).distance(q) + }; + let drag_id = egui::Id::new("curve-param-drag"); + let cursor_id = egui::Id::new("curve-param-cursor"); + // the region being dragged, and the graph x the drag is anchored at (stored as an `Option` so a + // stale value can be cleared, as the point-curve editor's drag state is) + let mut dragged: Option<(usize, f64)> = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); + let hovered = dragged.is_none() && resp.hover_pos().is_some_and(|q| near_curve(q) <= PARAM_GRAB); let mut set: Option<(&'static str, f64)> = None; - // The handle is the one under the *press*, not under where the pointer is once the drag - // threshold has passed: that can be most of a handle's own width away, so hit-testing the - // current position would grab the wrong control (or none at all on a short, quick drag). - let press = ui.input(|i| i.pointer.press_origin()); - let grab = press.or(resp.interact_pointer_pos()); + // the press, not where the pointer is once the drag threshold has passed: a press slightly off + // the line is still a grab, and the pointer has usually left that spot by the time it starts + let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); if resp.drag_started() - && let Some(h) = grab.and_then(|q| param_handle_at(&model, r, q)) + && let Some(q) = press + && near_curve(q) <= PARAM_GRAB { + let x = from_screen_x(q); let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"})); - dragged = Some(h); + dragged = Some((model.region_at(x), x)); + ui.data_mut(|dd| dd.insert_temp(cursor_id, to_screen(x, model.at(x)))); } - if let Some(h) = dragged + if let Some((region, x)) = dragged && resp.dragged() { - // The drag follows how far the pointer has moved, not where it is: the cursor it tracks is - // seeded at the handle's own centre (so grabbing a handle never makes its control jump by - // the distance between the pointer and that centre) and then advances by the pointer's - // motion. ⌥ = a tenth of the motion, as the point curve's drag does. - let cursor_id = egui::Id::new("curve-param-cursor"); + // Follow the pointer's motion from where the curve was grabbed, so the press itself moves + // nothing and the line tracks the hand. ⌥ = a tenth of the motion, as the point curve does. let alt = ui.input(|i| i.modifiers.alt); let dd = ui.input(|i| i.pointer.delta()); let step = if alt { vec2(dd.x * 0.1, dd.y * 0.1) } else { vec2(dd.x, dd.y) }; - let start = model.handle_screen_start(h, r); - // seeded at the handle's centre (so the grab itself moves nothing) and advanced by the - // pointer's motion from there - let advanced = ui.data(|d| d.get_temp::(cursor_id)).unwrap_or(start) + step; - ui.data_mut(|d| d.insert_temp(cursor_id, advanced)); - let level = ((r.bottom() - advanced.y) / r.height()).clamp(0.0, 1.0) as f64; - set = Some(match h { - ParamHandle::Region(region) => (h.control(), model.amount_at_level(region, level)), - ParamHandle::Split(split) => { - let x = ((advanced.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64; - (h.control(), model.split_value(split, x)) - } - }); + let start = to_screen(x, model.handle_level_at(region, model.amounts[region], x)); + let cursor = ui.data(|d| d.get_temp::(cursor_id)).unwrap_or(start) + step; + ui.data_mut(|d| d.insert_temp(cursor_id, cursor)); + let level = ((r.bottom() - cursor.y) / r.height()).clamp(0.0, 1.0) as f64; + set = Some((PARAM_AMOUNTS[region], model.amount_at_level(region, level, x))); } if let Some((id, v)) = set { let _ = app.run("develop.set", json!({"control": id, "value": v})); } - if commit_interaction(app, ui, dragged.is_some(), resp.drag_stopped()) { + let dropped = resp.drag_stopped(); + if commit_interaction(app, ui, dragged.is_some(), dropped) { dragged = None; - // the next drag seeds its cursor from its own handle, not from where this one finished - ui.data_mut(|d| d.remove::(egui::Id::new("curve-param-cursor"))); + ui.data_mut(|d| { + d.remove::(cursor_id); + d.remove::>(drag_id); + }); } ui.data_mut(|dd| dd.insert_temp(drag_id, dragged)); if dragged.is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } else if hovered.is_some() { + } else if hovered { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } - // the handles: a region's on its own bump, a split's a notch on the bottom edge - let p = ui.painter(); - for region in 0..PARAM_AMOUNTS.len() { - let at = to_screen(model.region_x(region), model.handle_level(region, model.amounts[region])); - let on = dragged == Some(ParamHandle::Region(region)) || hovered == Some(ParamHandle::Region(region)); - p.circle_filled(at, if on { 5.5 } else { 4.0 }, Color32::from_gray(230)); - p.circle_stroke(at, if on { 5.5 } else { 4.0 }, Stroke::new(1.0, t.canvas)); - } - for split in 0..PARAM_SPLITS.len() { - let at = to_screen(model.split_x(split), 0.0); - let on = dragged == Some(ParamHandle::Split(split)) || hovered == Some(ParamHandle::Split(split)); - let (w, h) = if on { (2.5, 9.0) } else { (1.5, 6.0) }; - p.rect_filled(Rect::from_min_max(pos2(at.x - w / 2.0, at.y - h), pos2(at.x + w / 2.0, at.y + h)), 1.0, Color32::from_gray(190)); - } - // what the handle in hand (or under the pointer) holds, as its slider below reads it - if let Some(h) = dragged.or(hovered) { - let label = controls::get(d, h.control()).map(|v| { - let name = controls::find(h.control()).map_or("", |s| s.label); - if v > 0.0 { format!("{name} {v:+.0}") } else { format!("{name} {v:.0}") } - }); - if let Some(label) = label { - p.text(r.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, label, t.font(11.0), t.text_dim); + // the region under the pointer (or the one in hand), named as its slider below reads it + let shown = dragged.map(|(region, _)| region).or_else(|| hovered.then(|| resp.hover_pos()).flatten().map(|q| model.region_at(from_screen_x(q)))); + if let Some(region) = shown { + let id = PARAM_AMOUNTS[region]; + if let Some(v) = controls::get(d, id) { + let name = controls::find(id).map_or("", |s| s.label); + ui.painter().text(r.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, format!("{name} {v:.0}"), t.font(11.0), t.text_dim); } } } -/// Keeps a split clear of its neighbours on the graph. -const SPLIT_MIN_GAP: f64 = 0.01; - -/// A region handle of the parametric graph: which of the four regions, or which of the three splits -/// between them. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum ParamHandle { - /// Region `0..4` (0 = the darkest), grabbed by its slider's amount. - Region(usize), - /// Split `0..3`, grabbed on the graph's bottom edge. - Split(usize), -} - -impl ParamHandle { - /// The control this handle writes. - pub(crate) fn control(self) -> &'static str { - match self { - ParamHandle::Region(r) => PARAM_AMOUNTS.get(r).copied().unwrap_or(PARAM_AMOUNTS[0]), - ParamHandle::Split(s) => PARAM_SPLITS.get(s).copied().unwrap_or(PARAM_SPLITS[0]), - } - } -} - -/// The handle under `q`, if any is within [`CURVE_HIT`]. -/// -/// A split's notch is looked for first. It lives on the graph's bottom edge, where the curve itself -/// passes on the dark end of a shallow line, so on equal terms a region handle would always win and -/// the notch could never be grabbed. -fn param_handle_at(model: &ParametricCurve, r: Rect, q: Pos2) -> Option { - let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); - let near = |at: Pos2| at.distance(q) <= CURVE_HIT; - for split in 0..PARAM_SPLITS.len() { - if near(to_screen(model.split_x(split), 0.0)) { - return Some(ParamHandle::Split(split)); - } - } - let closest = (0..PARAM_AMOUNTS.len()) - .map(|region| (region, to_screen(model.region_x(region), model.handle_level(region, model.amounts[region])).distance(q))) - .min_by(|a, b| a.1.total_cmp(&b.1))?; - (closest.1 <= CURVE_HIT).then_some(ParamHandle::Region(closest.0)) -} - fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { let t = Tokens::get(ui.ctx()); // channel selector @@ -1309,7 +1251,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let pts: Vec = (0..=CURVE_SAMPLES).map(|i| to_screen(i as f64 / CURVE_SAMPLES as f64, model.at(i as f64 / CURVE_SAMPLES as f64))).collect(); p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220)))); - param_handles(app, ui, r, &resp, d); + param_drag(app, ui, r, &resp, d); curve_footer(app, ui, d); for c in PARAM_AMOUNTS { control(app, ui, d, c, true); diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs index e918d8d60..7dd86e512 100644 --- a/crates/ui-egui/src/tests_curve.rs +++ b/crates/ui-egui/src/tests_curve.rs @@ -396,56 +396,37 @@ fn one_graph_drag_that_moves_several_regions_is_one_undo_step() { assert_eq!(develop(&h).curve, before, "undo takes back the whole gesture at once"); } -/// The screen position of a region's handle: its region's window centre, at the level the region's -/// own bump puts the curve, so a drag starts on the dot the graph draws. -fn region_handle(r: [f32; 4], c: &lightcraft_develop::ToneCurve, region: usize) -> [f32; 2] { - let model = ParametricCurve::new(c); - let x = model.region_x(region); - let y = model.handle_level(region, model.amounts[region]); - [r[0] + x as f32 * r[2], r[1] + (1.0 - y as f32) * r[3]] +/// The screen point where a drag should grab the curve: graph fraction `fx`, on the line itself. +fn on_curve(r: [f32; 4], c: &lightcraft_develop::ToneCurve, fx: f32) -> [f32; 2] { + let y = ParametricCurve::new(c).at(fx as f64); + [r[0] + fx * r[2], r[1] + (1.0 - y as f32) * r[3]] } -/// The screen position of a split's notch, on the graph's bottom edge. -fn split_handle(r: [f32; 4], c: &lightcraft_develop::ToneCurve, split: usize) -> [f32; 2] { - let model = ParametricCurve::new(c); - [r[0] + model.split_x(split) as f32 * r[2], r[1] + r[3]] -} - -/// The point to press to grab a split's notch: its x, a few points up from the graph's bottom edge -/// (the notch is drawn on the edge, and a press exactly on the widget's border falls outside egui's -/// hit rect). Well inside [`CURVE_HIT`] of the notch. -fn split_grab(r: [f32; 4], c: &lightcraft_develop::ToneCurve, split: usize) -> [f32; 2] { - let [x, y] = split_handle(r, c, split); - [x, y - 3.0] -} - -/// Dragging a region's handle on the graph bends that region and moves its slider with it, live: -/// the value under the pointer while the drag is in flight, and the same value when it lands. +/// Dragging the curve itself bends the region under the pointer and moves that region's slider with +/// it, live. The graph draws no handles, so the only thing to grab is the line. #[test] -fn dragging_a_region_handle_moves_its_slider() { +fn dragging_the_curve_moves_the_region_slider() { let mut h = curve_open("parametric"); exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); h.step(); let r = curve_graph_rect(&mut h); let c = develop(&h).curve; let undo_before = h.app.session.undo.len(); - let [x, y] = region_handle(r, &c, 2); - let down = 0.08 * r[3]; - let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y + down, "steps": 8}), T); + // grab the line inside the Lights window (x = 0.625) and pull it down + let [x, y] = on_curve(r, &c, 0.625); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y + 0.08 * r[3], "steps": 8}), T); assert_eq!(d["ok"], true, "{d}"); h.step(); let after = develop(&h).curve; - assert!(after.lights < 60.0, "the Lights slider follows the handle down: {}", after.lights); + assert!(after.lights < 60.0, "the Lights slider follows the line down: {}", after.lights); assert!(after.lights > 0.0, "and lands on a value, not at the bottom: {}", after.lights); assert_eq!((after.shadows, after.darks, after.highlights), (0.0, 0.0, 0.0), "the other regions stay put: {after:?}"); assert_eq!(after.split_shadows, c.split_shadows, "and the splits are untouched"); assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step for the whole drag"); - // the graph is redrawn bent, with the handle where the drag left it + // the graph really is redrawn below where the line was let graph = curve_graph_rect(&mut h); - let model = ParametricCurve::new(&after); - let painted = painted_param_curve(&h, graph)(model.region_x(2)); - let want = model.handle_level(2, after.lights); - assert!((painted - want).abs() < 0.03, "the handle sits on the redrawn curve: painted {painted}, handle {want}"); + let painted = painted_param_curve(&h, graph)(0.625); + assert!(painted < c.lights / 100.0 + 0.625, "the painted line followed the drag down: {painted}"); // and Undo takes the whole gesture back to where it started exec(&mut h, "edit.undo", json!({})); assert_eq!(develop(&h).curve, c, "undo restores the value the drag started from"); @@ -453,61 +434,60 @@ fn dragging_a_region_handle_moves_its_slider() { /// Dragging up bends the region the other way, so the gesture is not just "downwards works". #[test] -fn dragging_a_region_handle_up_raises_its_slider() { +fn dragging_the_curve_up_raises_the_region_slider() { let mut h = curve_open("parametric"); let r = curve_graph_rect(&mut h); let c = develop(&h).curve; - let [x, y] = region_handle(r, &c, 1); + // x = 0.375 is inside the Darks window + let [x, y] = on_curve(r, &c, 0.375); let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y - 0.08 * r[3], "steps": 8}), T); assert_eq!(d["ok"], true, "{d}"); h.step(); let after = develop(&h).curve; - assert!(after.darks > 0.0, "the Darks slider follows the handle up: {}", after.darks); + assert!(after.darks > 0.0, "the Darks slider follows the line up: {}", after.darks); assert_eq!((after.shadows, after.lights, after.highlights), (0.0, 0.0, 0.0), "the other regions stay put: {after:?}"); - // the drawn curve really did move up where that region lives - assert!(ParametricCurve::new(&after).at(0.375) > ParametricCurve::new(&c).at(0.375), "the curve bends up at the Darks region"); + assert!(ParametricCurve::new(&after).at(0.375) > ParametricCurve::new(&c).at(0.375), "the curve is drawn bent up at the Darks region"); } -/// Dragging a split's notch moves the region boundary — the same split slider the pipeline reads — -/// leaving the four region amounts alone, in one undo step. +/// Which region a drag moves is decided by where on the line it is grabbed: the same drag gesture +/// four times over grabs four different sliders. #[test] -fn dragging_a_split_moves_the_region_boundary() { - let mut h = curve_open("parametric"); - exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 40})); - h.step(); - let r = curve_graph_rect(&mut h); - let c = develop(&h).curve; - let undo_before = h.app.session.undo.len(); - let [x, y] = split_grab(r, &c, 1); - let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 0.1 * r[2], "toY": y, "steps": 8}), T); - assert_eq!(d["ok"], true, "{d}"); - h.step(); - let after = develop(&h).curve; - assert!(after.split_mid > c.split_mid, "the Midtones split follows the notch right: {} → {}", c.split_mid, after.split_mid); - assert_eq!((after.shadows, after.darks, after.lights, after.highlights), (0.0, 0.0, 40.0, 0.0), "the region amounts stay put"); - assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step for the drag"); - // dragged far right it stops inside the split's own range, without crossing the next split - let [x, y] = split_grab(r, &after, 1); - let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 0.9 * r[2], "toY": y, "steps": 8}), T); - assert_eq!(d["ok"], true, "{d}"); - h.step(); - let far = develop(&h).curve; - assert!((20.0..=80.0).contains(&far.split_mid), "the split stays in its slider's range: {}", far.split_mid); - let model = ParametricCurve::new(&far); - assert!(model.split_x(1) < model.split_x(2), "and stays left of the split after it: {far:?}"); +fn the_region_dragged_is_the_one_under_the_pointer() { + for (fx, control, region) in + [(0.125_f32, "curve.shadows", 0usize), (0.375, "curve.darks", 1), (0.625, "curve.lights", 2), (0.875, "curve.highlights", 3)] + { + let mut h = curve_open("parametric"); + let r = curve_graph_rect(&mut h); + let c = develop(&h).curve; + assert_eq!(ParametricCurve::new(&c).region_at(fx as f64), region, "x = {fx} belongs to {control}"); + let [x, y] = on_curve(r, &c, fx); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y - 0.06 * r[3], "steps": 6}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + let moved = after.shadows + after.darks + after.lights + after.highlights; + let want = match region { + 0 => after.shadows, + 1 => after.darks, + 2 => after.lights, + _ => after.highlights, + }; + assert!(want > 0.0, "grabbing at {fx} raises {control}: {after:?}"); + assert!((moved - want).abs() < 1e-9, "and nothing else moved: {after:?}"); + } } -/// A drag that starts away from every handle is not a graph gesture at all: nothing moves and no -/// undo step is made (the graph is not a "click anywhere to bend" surface). +/// A drag that starts away from the line is not a graph gesture at all: nothing moves and no undo +/// step is made (the graph is not a "click anywhere to bend" surface). #[test] -fn a_parametric_drag_off_a_handle_changes_nothing() { +fn a_parametric_drag_off_the_line_changes_nothing() { let mut h = curve_open("parametric"); exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 40})); h.step(); let before = develop(&h).curve; let undo = h.app.session.undo.len(); let r = curve_graph_rect(&mut h); - // a corner of the graph, far from the curve and from both handle rows + // a corner of the graph, far above the line let d = h.request( "ui.drag", json!({"x": r[0] + 0.06 * r[2], "y": r[1] + 0.06 * r[3], "toX": r[0] + 0.2 * r[2], "toY": r[1] + 0.3 * r[3], "steps": 6}), @@ -519,52 +499,65 @@ fn a_parametric_drag_off_a_handle_changes_nothing() { assert_eq!(h.app.session.undo.len(), undo, "and no undo step was made"); } -/// The graph shows the value under the pointer while the drag is in flight, so the slider and the -/// handle are seen to agree mid-gesture, not only once it lands. +/// Hovering the line names the region a press there would move (and offers the grab cursor), so the +/// graph says which slider it is about to change before the drag starts. #[test] -fn the_value_under_the_pointer_reads_out_during_the_drag() { +fn hovering_the_line_names_the_region_it_would_move() { let mut h = curve_open("parametric"); exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); h.step(); let r = curve_graph_rect(&mut h); - let c = develop(&h).curve; - let [x, y] = region_handle(r, &c, 2); - // press on the handle and hover the same place: the readout names the control it will write + let [x, y] = on_curve(r, &develop(&h).curve, 0.625); let q = h.request("ui.move", json!({"x": x, "y": y}), T); assert_eq!(q["ok"], true, "{q}"); h.step(); - assert!(h.painted_text().iter().any(|t| t.contains("Lights")), "the handle names the slider it writes: {:?}", h.painted_text()); + assert_eq!(h.last_cursor, egui::CursorIcon::Grab, "the line offers a grab: {:?}", h.last_cursor); + assert!(h.painted_text().iter().any(|t| t.contains("Lights")), "and names the slider it would move: {:?}", h.painted_text()); + // well above the line there is no grab and no name + let q = h.request("ui.move", json!({"x": x, "y": r[1] + 0.06 * r[3]}), T); + assert_eq!(q["ok"], true, "{q}"); + h.step(); + assert_eq!(h.last_cursor, egui::CursorIcon::Default, "away from the line there is nothing to grab: {:?}", h.last_cursor); } -/// A region's handle level and its slider amount are inverses, so a drag puts the handle exactly -/// where the pointer is; a level the region cannot reach saturates rather than jumping. +/// A point's level on the region's curve and its slider amount are inverses, so a drag puts the line +/// exactly where the hand is, at whatever point on the graph it was grabbed. A level the region +/// cannot reach saturates rather than jumping. #[test] -fn a_region_level_and_its_amount_are_inverses() { +fn a_curve_level_and_its_amount_are_inverses() { let c = lightcraft_develop::ToneCurve { shadows: 20.0, lights: 40.0, ..Default::default() }; let model = ParametricCurve::new(&c); for region in 0..4 { - for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] { - let level = model.handle_level(region, amount); - let back = model.amount_at_level(region, level); - assert!((back - amount).abs() <= 1.0, "region {region}: {amount} → level {level} → {back}"); + let centre = model.region_x(region); + // the region's window centre, and a point off-centre inside the same window + for x in [centre, centre + (model.split_x(region) - centre) * 0.5] { + for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] { + let level = model.handle_level_at(region, amount, x); + let back = model.amount_at_level(region, level, x); + assert!((back - amount).abs() <= 1.0, "region {region} at {x}: {amount} → {level} → {back}"); + } } } - assert_eq!(model.amount_at_level(3, 1.0), 100.0, "a level above the region's reach saturates high"); - assert_eq!(model.amount_at_level(3, 0.0), -100.0, "and one below saturates low"); - // a split reads out in its own slider's range (10..=70 here), not the graph's 0..=100 … - assert_eq!(model.split_value(0, 0.0), 10.0, "the Shadows split bottoms out at its slider's low end"); - // … and its neighbours are what actually bound it, closer than that range's own ends - assert!(model.split_value(0, 1.0) <= 70.0 && model.split_value(0, 1.0) > 10.0, "kept inside its range"); - assert!(model.split_x(1) > model.split_x(0) && model.split_x(2) > model.split_x(1), "and the splits stay in order"); - assert!(model.split_value(2, 1.0) <= 90.0, "the Highlights split stays in its own range too"); + let centre = model.region_x(3); + assert_eq!(model.amount_at_level(3, 1.0, centre), 100.0, "a level above the region's reach saturates high"); + assert_eq!(model.amount_at_level(3, 0.0, centre), -100.0, "and one below saturates low"); } -/// A dragged split never crosses its neighbours, however far the pointer goes. +/// The four regions tile the graph: every position belongs to exactly one of them, in order, so a +/// drag anywhere on the line moves exactly one slider and the one the pointer is over. #[test] -fn a_dragged_split_stays_between_its_neighbours() { +fn the_four_regions_tile_the_graph() { let model = ParametricCurve::new(&lightcraft_develop::ToneCurve::default()); - assert!((model.split_x_limited(1, 0.0) - 0.26).abs() < 1e-9, "Midtones stops just right of the split before it"); - assert!((model.split_x_limited(1, 1.0) - 0.74).abs() < 1e-9, "and just left of the split after it"); - assert!(model.split_x_limited(0, -1.0).abs() < 1e-9, "the first split can reach black"); - assert!((model.split_x_limited(2, 2.0) - 1.0).abs() < 1e-9, "the last can reach white"); + assert_eq!(model.region_at(0.0), 0, "black is the darkest region"); + assert_eq!(model.region_at(1.0), 3, "white is the lightest"); + assert_eq!(model.region_at(0.2), 0, "below the first split"); + assert_eq!(model.region_at(0.3), 1, "between the first two splits"); + assert_eq!(model.region_at(0.6), 2); + assert_eq!(model.region_at(0.8), 3); + let mut prev = model.region_at(0.0); + for i in 0..=100 { + let r = model.region_at(i as f64 / 100.0); + assert!(r >= prev, "the regions never go backwards: {r} after {prev}"); + prev = r; + } }