diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f23c9bd7..ac747ac51 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,8 +101,11 @@ mod tests_grading; #[cfg(test)] mod tests_grid; #[cfg(test)] +#[cfg(test)] mod tests_guided_guides; #[cfg(test)] +mod tests_histogram_drag; +#[cfg(test)] mod tests_info_dialogs; #[cfg(test)] mod tests_keymap; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 082508d08..5f5322a0a 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -76,6 +76,13 @@ pub enum Gesture { at: Point, acc: f32, }, + /// A drag of one of the histogram's tonal zones (see [`super::histogram`]): the control the + /// press locked and the unrounded value its sideways travel has reached. It holds no canvas + /// geometry: the zone stays locked wherever the pointer goes. + HistogramDrag { + control: &'static str, + value: f64, + }, } /// Screen ↔ normalized-image mapping for the displayed frame. diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 08f70bd02..c1a29b99e 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -630,12 +630,19 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // row under the plot let hdr = hist.as_ref().and_then(|h| h.hdr); let legend = if hdr.is_some() { 26.0 } else { 0.0 }; - let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click_and_drag()); crate::widgets::announce(&resp, crate::i18n::tr("Histogram")); register(ui.ctx(), "histogram", r); let p = ui.painter(); p.rect_filled(r, 0.0, t.button); let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0 - legend)); + // Lightroom Classic's tonal zones (Blacks / Shadows / Exposure / Highlights / Whites across the + // plot): a drag over one moves its control (the band and the readout show which and how far), a + // double-click puts it back to its default, and Shift drags a tenth as far + let zone = super::histogram::interact(app, ui, &resp, plot, id); + if let Some(a) = &zone { + super::histogram::paint_zone(p, plot, a, &t); + } if let Some(b) = hdr { // white for SDR, then one band per stop above SDR white: white too, or (Visualize HDR // range on) in its colours @@ -723,13 +730,11 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let mut clicked = resp.clicked(); // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep // clear of it, the left one too so they stay symmetric (#1640) - let scroll = ui.spacing().scroll; - let inset = 20.0 + scroll.bar_width + scroll.bar_outer_margin; - for (name, x, label, clipped) in [ - ("shadows", plot.left() + inset, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), - ("highlights", plot.right() - inset, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), + let [shadow_rect, highlight_rect] = super::histogram::clipping_rects(ui, plot); + for (name, rect, label, clipped) in [ + ("shadows", shadow_rect, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), + ("highlights", highlight_rect, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), ] { - let rect = Rect::from_center_size(pos2(x, plot.top() + 14.0), vec2(24.0, 22.0)); let response = ui.interact(rect, ui.id().with(("histogram-clipping", name)), Sense::click()); register(ui.ctx(), format!("histogram:{name}"), rect); response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); @@ -776,8 +781,11 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if clicked { let _ = app.run("view.clipping", json!({})); } - // EXIF strip - if let Some(ph) = app.session.catalog.photo(id) { + // the tonal zone the pointer is on replaces the EXIF strip while it is there + if let Some(text) = zone.as_ref().and_then(|a| a.readout()) { + register(ui.ctx(), "histogramReadout", r); + p.text(pos2(r.left() + r.width() / 2.0, r.bottom() - 13.0), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); + } else if let Some(ph) = app.session.catalog.photo(id) { let m = &ph.meta; let parts: Vec = [ m.iso.map(|i| format!("ISO {i}")), diff --git a/crates/ui-egui/src/panels/histogram.rs b/crates/ui-egui/src/panels/histogram.rs new file mode 100644 index 000000000..e6c0b50c1 --- /dev/null +++ b/crates/ui-egui/src/panels/histogram.rs @@ -0,0 +1,372 @@ +//! The histogram's tonal zones, as in Lightroom Classic: hovering one names it and shows its value, +//! dragging one sideways moves its Basic control, and a double-click puts that control back to its +//! default. +//! +//! The zone a press landed in stays locked for the whole drag, so the pointer may cross the others; +//! every step is a `develop.set` between `develop.beginInteraction` and `develop.endInteraction`, the +//! path the panel's sliders take: the draft render follows the drag live and the whole drag lands as +//! one undo step (`end_interaction`). + +use egui::{Painter, Pos2, Rect, Response, pos2}; +use lightcraft_catalog::PhotoId; +use lightcraft_develop::{ControlSpec, controls}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::panels::detail::Gesture; +use crate::theme::Tokens; +use crate::widgets::{register, shown_value}; + +/// One tonal zone across the histogram's plot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Zone { + /// The develop control the zone adjusts: an id of [`lightcraft_develop::controls`]. + pub control: &'static str, + /// The zone's span of the plot's width, normalized 0 (left) to 1 (right). + pub from: f32, + pub to: f32, +} + +/// Lightroom Classic's five histogram regions, left to right: Blacks, Shadows, Exposure, Highlights, +/// Whites. Each names the Basic panel's own control, so the zone has that control's range, default +/// and steps. +pub const ZONES: [Zone; 5] = [ + Zone { control: "light.blacks", from: 0.00, to: 0.15 }, + Zone { control: "light.shadows", from: 0.15, to: 0.35 }, + Zone { control: "light.exposure", from: 0.35, to: 0.65 }, + Zone { control: "light.highlights", from: 0.65, to: 0.85 }, + Zone { control: "light.whites", from: 0.85, to: 1.00 }, +]; + +/// How far a sideways drag moves a control: crossing the whole plot sweeps the control's whole range, +/// as dragging a slider's thumb across its track does. Shift is a tenth of that, as on the sliders. +const SENSITIVITY: f64 = 1.0; +const FINE: f64 = 0.1; + +impl Zone { + /// The zone's control spec; `None` for an id the control table no longer has. + pub fn spec(self) -> Option<&'static ControlSpec> { + controls::find(self.control) + } + + /// The zone's band in `plot`. + pub fn rect(self, plot: Rect) -> Rect { + let x = |f: f32| plot.left() + plot.width() * f; + Rect::from_min_max(pos2(x(self.from), plot.top()), pos2(x(self.to), plot.bottom())) + } +} + +/// The zone covering `fx`, a position across the plot normalized to 0..1. `None` off the plot (NaN +/// too); 1.0 is the last zone's, whose right edge is the plot's. +pub fn zone_at(fx: f32) -> Option { + if !(0.0..=1.0).contains(&fx) { + return None; + } + ZONES.iter().copied().find(|zone| fx >= zone.from && fx < zone.to).or_else(|| ZONES.last().copied()) +} + +/// The value `base` moved by a sideways drag of `dx` points over a plot `width` points wide. The +/// result is clamped to the control's range; a drag that can't be measured (no width, a hostile +/// delta) or a non-finite `base` leaves `base` alone. +pub fn dragged(spec: &ControlSpec, base: f64, dx: f32, width: f32, fine: bool) -> f64 { + if !width.is_finite() || width <= 0.0 { + return base; + } + let travel = f64::from(dx) / f64::from(width) * if fine { FINE } else { SENSITIVITY }; + let v = base + travel * (spec.max - spec.min); + if v.is_finite() { v.clamp(spec.min, spec.max) } else { base } +} + +/// `v` on the control's own steps, as the panel's sliders round and dispatch it. A non-finite `v` +/// gives the control's default, as [`ControlSpec::clamp`] does. +pub fn quantized(spec: &ControlSpec, v: f64) -> f64 { + if !v.is_finite() { + return spec.default; + } + let step = spec.step.max(1e-9); + ((v / step).round() * step).clamp(spec.min, spec.max) +} + +/// What the histogram is acting on this frame. +#[derive(Clone, Copy, Debug)] +pub struct Active { + /// The zone under the pointer, or the one a drag locked. + pub zone: Zone, + /// The zone's control's live value. + pub value: f64, + /// A drag of the zone is in progress: its zone stays locked until the release. + pub dragging: bool, +} + +impl Active { + /// The readout under the histogram: "Exposure: +0.45 EV", "Shadows: +25". + pub fn readout(&self) -> Option { + let spec = self.zone.spec()?; + let shown = shown_value(spec, self.value); + let value = if spec.id == "light.exposure" { format!("{shown} EV") } else { shown }; + Some(format!("{}: {value}", crate::i18n::tr(spec.label))) + } +} + +/// The rectangles of the clipping buttons in the plot's top corners (shadows left, highlights right). +pub fn clipping_rects(ui: &egui::Ui, plot: Rect) -> [Rect; 2] { + let scroll = ui.spacing().scroll; + clipping_rects_in(plot, scroll.bar_width, scroll.bar_outer_margin) +} + +/// The clipping indicator rectangles for a given `plot` and scroll-bar geometry. +pub fn clipping_rects_in(plot: Rect, scroll_bar_width: f32, scroll_bar_outer_margin: f32) -> [Rect; 2] { + let inset = 20.0 + scroll_bar_width + scroll_bar_outer_margin; + [ + Rect::from_center_size(pos2(plot.left() + inset, plot.top() + 14.0), egui::vec2(24.0, 22.0)), + Rect::from_center_size(pos2(plot.right() - inset, plot.top() + 14.0), egui::vec2(24.0, 22.0)), + ] +} + +/// The histogram's pointer input for this frame: the press locks its zone and opens the interaction, +/// the drag moves that control, a double-click resets it. Returns what the highlight and the readout +/// should show, `None` when the pointer is nowhere near a zone. +/// +/// `plot` is the plot's rect: the strip under it (the readout and the EXIF line) is not a zone, and +/// neither are the clipping buttons in the plot's top corners, which take their own presses. +pub fn interact(app: &mut LightcraftApp, ui: &egui::Ui, resp: &Response, plot: Rect, id: PhotoId) -> Option { + let width = plot.width(); + let clips = clipping_rects(ui, plot); + let in_plot = |q: Pos2| plot.contains(q) && !clips.iter().any(|c| c.contains(q)); + let zone_of = |q: Pos2| in_plot(q).then(|| (q.x - plot.left()) / width).and_then(zone_at); + // the zone the pointer is over, and the one a press (or a double-click) landed in: the press's + // origin, so a drag is judged by where it started + let hovered = resp.hover_pos().and_then(zone_of); + let pressed = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()).and_then(zone_of); + let was_dragging = matches!(app.gesture, Some(Gesture::HistogramDrag { .. })); + // a drag cut short — the release went elsewhere, the panel closed — leaves the interaction open: + // with no button held the gesture is over, as the crop tool's gestures decide too + if was_dragging && !resp.drag_stopped() && !ui.input(|i| i.pointer.any_down()) { + finish(app); + } + // 1. the press locks its zone: one interaction holds the whole drag as one undo step + if resp.drag_started() + && !was_dragging + && let Some(zone) = pressed + && let Some(spec) = zone.spec() + && app.run("develop.beginInteraction", json!({"label": spec.label})).is_ok() + { + app.ui.dragging_control = Some(spec.id.to_string()); + app.gesture = Some(Gesture::HistogramDrag { control: zone.control, value: value_of(app, id, spec) }); + } + // 2. the drag moves the locked zone's control, wherever the pointer has gone since + if let Some(Gesture::HistogramDrag { control, value }) = app.gesture.clone() + && let Some(spec) = controls::find(control) + { + if resp.dragged() || resp.drag_stopped() { + // The frame a drag is first reported, take the whole travel from the press: egui only + // starts reporting a drag once the pointer has left its click threshold, and those first + // points are the user's movement too. Later frames add their own delta (a one-frame flick + // reports the drag as started and stopped together: then this is its delta), so Shift — + // a tenth — scales the travel it was held for, as the sliders' fine drag does. The + // unrounded value is kept from frame to frame, so a slow drag adds up instead of rounding + // back to the step it already has. + let dx = if resp.drag_started() { + ui.input(|i| i.pointer.total_drag_delta()).map_or(resp.drag_delta().x, |d| d.x) + } else { + resp.drag_delta().x + }; + let next = dragged(spec, value, dx, width, ui.input(|i| i.modifiers.shift)); + app.gesture = Some(Gesture::HistogramDrag { control, value: next }); + let v = quantized(spec, next); + if value_of(app, id, spec) != v { + let _ = app.run("develop.set", json!({"control": control, "value": v})); + } + } + if resp.drag_stopped() { + finish(app); + } + } + // 3. a double-click in a zone puts that control back to its default, in one undo step + if resp.double_clicked() + && let Some(zone) = pressed + && let Some(spec) = zone.spec() + { + let _ = app.run("develop.beginInteraction", json!({"label": spec.label})); + app.ui.dragging_control = Some(spec.id.to_string()); + let _ = app.run("develop.set", json!({"control": spec.id, "value": spec.default})); + finish(app); + } + // the highlight and the readout follow the drag's locked zone, else the pointer's + let zone = match app.gesture { + Some(Gesture::HistogramDrag { control, .. }) => ZONES.iter().copied().find(|zone| zone.control == control), + _ => hovered, + }?; + let spec = zone.spec()?; + let dragging = matches!(app.gesture, Some(Gesture::HistogramDrag { .. })); + if dragging || hovered.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + register(ui.ctx(), "histogram:zone", zone.rect(plot)); + Some(Active { zone, value: value_of(app, id, spec), dragging }) +} + +/// End the histogram drag: the interaction commits the whole drag as one undo step. +fn finish(app: &mut LightcraftApp) { + app.gesture = None; + app.ui.dragging_control = None; + let _ = app.run("develop.endInteraction", json!({})); +} + +/// A control's value on the photo, or its default when the photo has no settings yet. +fn value_of(app: &LightcraftApp, id: PhotoId, spec: &ControlSpec) -> f64 { + controls::get(&app.session.develop_of(id).unwrap_or_default(), spec.id).unwrap_or(spec.default) +} + +/// The band the active zone draws over the plot: a subtle translucent highlight, a little stronger +/// while the zone is being dragged. Painted behind the histogram's curves. +pub fn paint_zone(painter: &Painter, plot: Rect, active: &Active, tokens: &Tokens) { + let tint = if active.dragging { tokens.pressed } else { tokens.hover }; + painter.rect_filled(active.zone.rect(plot), 0.0, tint.gamma_multiply(if active.dragging { 0.75 } else { 0.45 })); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec_of(control: &str) -> &'static ControlSpec { + controls::find(control).unwrap_or_else(|| panic!("{control} is not a control")) + } + + /// The five zones tile the plot left to right, each naming a real Basic tone control of the + /// range this app documents (the panel's own specs, not a copy of them). + #[test] + fn the_zones_tile_the_plot_and_name_real_tone_controls() { + assert_eq!(ZONES.len(), 5); + assert_eq!(ZONES.first().map(|z| z.from), Some(0.0)); + assert_eq!(ZONES.last().map(|z| z.to), Some(1.0)); + for pair in ZONES.windows(2) { + assert_eq!(pair[0].to, pair[1].from, "{:?} does not meet {:?}", pair[0].control, pair[1].control); + } + for zone in ZONES { + let spec = spec_of(zone.control); + assert_eq!(spec.section, lightcraft_develop::Section::Light, "{}", spec.id); + assert_eq!(spec.default, 0.0, "{}", spec.id); + let (min, max) = if spec.id == "light.exposure" { (-5.0, 5.0) } else { (-100.0, 100.0) }; + assert_eq!((spec.min, spec.max), (min, max), "{}", spec.id); + } + } + + /// A position across the plot picks its zone, at the boundaries too; anything off the plot — + /// including NaN — picks none. + #[test] + fn a_position_across_the_plot_picks_its_zone() { + for (fx, control) in [ + (0.0, "light.blacks"), + (0.14, "light.blacks"), + (0.15, "light.shadows"), + (0.34, "light.shadows"), + (0.35, "light.exposure"), + (0.64, "light.exposure"), + (0.65, "light.highlights"), + (0.84, "light.highlights"), + (0.85, "light.whites"), + (0.99, "light.whites"), + (1.0, "light.whites"), + ] { + assert_eq!(zone_at(fx).map(|z| z.control), Some(control), "{fx}"); + } + for off in [-0.01, -1.0, 1.01, f32::NAN, f32::INFINITY] { + assert!(zone_at(off).is_none(), "{off}"); + } + } + + /// The zones' bands are their share of the plot's width, in order. + #[test] + fn a_zones_band_is_its_share_of_the_plot() { + let plot = Rect::from_min_max(pos2(100.0, 20.0), pos2(400.0, 200.0)); + let bands: Vec = ZONES.iter().map(|z| z.rect(plot)).collect(); + assert_eq!(bands.first().map(|b| b.left()), Some(plot.left())); + assert_eq!(bands.last().map(|b| b.right()), Some(plot.right())); + for band in &bands { + assert_eq!((band.top(), band.bottom()), (plot.top(), plot.bottom())); + } + for pair in bands.windows(2) { + assert_eq!(pair[0].right(), pair[1].left()); + } + let exposure = bands.get(2).copied().unwrap_or(plot); + assert!((exposure.width() - plot.width() * 0.30).abs() < 1e-3, "{exposure:?}"); + } + + /// A drag across the whole plot sweeps the whole range; Shift is a tenth; the ends clamp; the + /// unrounded value a slow drag accumulates keeps adding up. + #[test] + fn a_drag_sweeps_the_control_and_clamps_at_its_ends() { + let exposure = spec_of("light.exposure"); + let blacks = spec_of("light.blacks"); + assert!((dragged(exposure, 0.0, 20.0, 200.0, false) - 1.0).abs() < 1e-9, "a tenth of the plot is a tenth of ±5 EV"); + assert!((dragged(blacks, 0.0, 20.0, 200.0, false) - 20.0).abs() < 1e-9); + assert!((dragged(blacks, 0.0, 200.0, 200.0, false) - 100.0).abs() < 1e-9); + assert_eq!(dragged(blacks, 0.0, 400.0, 200.0, false), 100.0, "past the right end it stays"); + assert_eq!(dragged(blacks, 0.0, -400.0, 200.0, false), -100.0, "and past the left end"); + assert!((dragged(blacks, 0.0, 20.0, 200.0, true) - 2.0).abs() < 1e-9, "Shift is a tenth of the travel"); + assert!((dragged(blacks, 0.0, -20.0, 200.0, false) + 20.0).abs() < 1e-9, "left lowers"); + // a drag whose frames are each smaller than one step still adds up over the whole drag (the + // deltas are f32 points, so the sum carries their rounding) + let mut acc = 0.0; + for _ in 0..10 { + acc = dragged(blacks, acc, 0.4, 200.0, false); + } + assert!((acc - 4.0).abs() < 1e-6, "{acc}"); + // hostile input: no width, a hostile delta, a base that isn't a number + assert_eq!(dragged(blacks, 5.0, 20.0, 0.0, false), 5.0); + assert_eq!(dragged(blacks, 5.0, 20.0, -10.0, false), 5.0); + assert_eq!(dragged(blacks, 5.0, f32::NAN, 200.0, false), 5.0); + assert_eq!(dragged(blacks, 5.0, f32::INFINITY, 200.0, false), 5.0); + assert!(dragged(blacks, f64::NAN, 20.0, 200.0, false).is_nan(), "a base that isn't a number stays as it is"); + } + + /// Dispatched values land on the control's steps (as the sliders dispatch them) and inside its + /// range; a value that isn't a number gives the default rather than a NaN. + #[test] + fn a_dispatched_value_lands_on_the_controls_steps() { + let blacks = spec_of("light.blacks"); + let exposure = spec_of("light.exposure"); + assert_eq!(quantized(blacks, 20.4), 20.0); + assert_eq!(quantized(blacks, 20.6), 21.0); + assert_eq!(quantized(blacks, 1e9), 100.0); + assert_eq!(quantized(blacks, -1e9), -100.0); + assert_eq!(quantized(blacks, f64::NAN), 0.0); + assert!((quantized(exposure, 0.454) - 0.45).abs() < 1e-9, "{}", quantized(exposure, 0.454)); + for v in [-1e9, -3.7, 0.0, 2.2, 1e9] { + let q = quantized(exposure, v); + assert!((exposure.min..=exposure.max).contains(&q), "{v} → {q}"); + } + } + + /// The readout names the zone's control and shows its value: EV for the exposure, the plain + /// signed number otherwise, exactly as the sliders show them. + #[test] + fn the_readout_names_the_zone_and_its_value() { + let active = |control: &str, value: f64, dragging: bool| Active { + zone: ZONES.iter().copied().find(|z| z.control == control).unwrap_or(ZONES[0]), + value, + dragging, + }; + assert_eq!(active("light.exposure", 0.45, false).readout().as_deref(), Some("Exposure: +0.45 EV")); + assert_eq!(active("light.exposure", -2.0, true).readout().as_deref(), Some("Exposure: -2.00 EV")); + assert_eq!(active("light.shadows", 25.0, true).readout().as_deref(), Some("Shadows: +25")); + assert_eq!(active("light.blacks", 0.0, false).readout().as_deref(), Some("Blacks: 0")); + assert_eq!(active("light.highlights", -100.0, false).readout().as_deref(), Some("Highlights: -100")); + assert_eq!(active("light.whites", 13.0, false).readout().as_deref(), Some("Whites: +13")); + } + + /// The clipping rectangles sit in the plot's top corners, symmetric from both edges. + #[test] + fn clipping_rects_sit_in_the_plots_top_corners() { + let plot = Rect::from_min_max(pos2(100.0, 20.0), pos2(400.0, 200.0)); + let [low, high] = clipping_rects_in(plot, 10.0, 4.0); + assert!(plot.contains(low.center())); + assert!(plot.contains(high.center())); + assert_eq!(low.top(), plot.top() + 3.0); + assert_eq!(high.top(), plot.top() + 3.0); + assert_eq!(low.size(), egui::vec2(24.0, 22.0)); + assert_eq!(high.size(), egui::vec2(24.0, 22.0)); + assert!((low.left() - plot.left() - (plot.right() - high.right())).abs() < 1e-4); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2fcb36663..ad8fcd374 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -13,6 +13,7 @@ pub mod empty_library; pub mod faces; pub mod filterbar; pub mod grid; +pub mod histogram; pub mod keymap; pub mod keyword_list; pub mod keywording; diff --git a/crates/ui-egui/src/tests_histogram_drag.rs b/crates/ui-egui/src/tests_histogram_drag.rs new file mode 100644 index 000000000..e6fd7138d --- /dev/null +++ b/crates/ui-egui/src/tests_histogram_drag.rs @@ -0,0 +1,295 @@ +//! The histogram's tonal zones, as in Lightroom Classic: dragging one sideways moves its Basic +//! control (one undo step for the whole drag), the zone locks at the press, a double-click resets +//! that control, and hovering highlights the zone and names it in the readout under the plot. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The zones' controls, left to right (`panels::histogram::ZONES`). +const CONTROLS: [&str; 5] = ["light.blacks", "light.shadows", "light.exposure", "light.highlights", "light.whites"]; + +/// The demo library in Detail with the Edit panel (the histogram sits first) on a selected photo. +fn edit_panel() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + let id = h.app.session.visible_cloned()[0].0; + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + widget(h, id).unwrap_or_else(|| panic!("no widget {id} on screen")) +} + +fn value(h: &Headless, control: &str) -> f64 { + let id = h.app.session.active().unwrap_or_else(|| panic!("no active photo")); + let spec = lightcraft_develop::controls::find(control).unwrap_or_else(|| panic!("no control {control}")); + lightcraft_develop::controls::get(&h.app.session.develop_of(id).unwrap_or_default(), control).unwrap_or(spec.default) +} + +/// Every zone's control, left to right. +fn values(h: &Headless) -> Vec { + CONTROLS.iter().map(|c| value(h, c)).collect() +} + +/// Set a control through the engine, as a slider would. +fn set(h: &mut Headless, control: &str, v: f64) { + h.app.run("develop.set", json!({"control": control, "value": v})).unwrap_or_else(|e| panic!("develop.set {control}: {e}")); +} + +/// Let the harness clock pass `seconds`, running frames. egui closes its double-click window when +/// the next click comes more than 0.3 s later *and* more than 6 points away; its triple-click +/// window is twice that, and two clicks in another zone right after two in one are close enough to +/// the second click to count as a triple click of it — which no longer reports `double_clicked`. +fn after(h: &mut Headless, seconds: f64) { + let until = h.time() + seconds; + while h.time() < until { + h.step(); + } +} + +/// Drag from a fraction `fx` across the histogram: `fy` 0.5 is inside the plot, `dx` in points. +fn drag(h: &mut Headless, fx: f32, dx: f32, shift: bool) { + let p = json!({"id": "histogram", "fx": fx, "fy": 0.5, "dx": dx, "steps": 8, "shift": shift}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); +} + +/// A double-click on the histogram at `fx` (the plot's own rows, `fy` 0.5). +fn double_click(h: &mut Headless, fx: f32) { + let r = h.request("ui.clickWidget", json!({"id": "histogram", "fx": fx, "fy": 0.5, "count": 2}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// Hover the histogram at `fx` and let the frame settle. +fn hover(h: &mut Headless, fx: f32) { + let r = h.request("ui.hoverWidget", json!({"id": "histogram", "fx": fx, "fy": 0.5}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +/// Dragging one zone moves that control: a sideways travel of the plot's width sweeps its whole +/// range, and the whole drag is one undo step. +#[test] +fn dragging_the_exposure_zone_moves_the_exposure_in_one_undo_step() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + let undo = h.app.session.undo.len(); + drag(&mut h, 0.5, w * 0.05, false); + // a twentieth of the plot: a twentieth of ±5 EV + let exposure = value(&h, "light.exposure"); + assert!((exposure - 0.5).abs() <= 0.02, "a twentieth of the plot is a twentieth of the range: {exposure}"); + assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step for the whole drag"); + assert!(h.app.session.interaction.is_none(), "the interaction is closed on the release"); + assert!(h.app.ui.dragging_control.is_none(), "no control is left dragged"); + let mut others = values(&h); + others.remove(2); + assert_eq!(others, [0.0, 0.0, 0.0, 0.0], "the zones the drag crossed are untouched"); + // one undo brings the exposure back + let r = h.request("engine.execute", json!({"command": "edit.undo"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(value(&h, "light.exposure"), 0.0); +} + +/// The zone a press landed in stays locked for the whole drag: dragging from Exposure across the +/// Shadows and Blacks zones still only moves the Exposure. +#[test] +fn the_zone_locks_at_the_press_however_far_the_drag_travels() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + let undo = h.app.session.undo.len(); + drag(&mut h, 0.5, -w * 0.4, false); + let exposure = value(&h, "light.exposure"); + assert!((exposure + 4.0).abs() <= 0.02, "four tenths of the plot to the left is −4 EV: {exposure}"); + assert_eq!((value(&h, "light.shadows"), value(&h, "light.blacks")), (0.0, 0.0), "the zones crossed stay untouched"); + assert_eq!(h.app.session.undo.len() - undo, 1, "still one undo step"); +} + +/// Dragging left lowers the zone's control and dragging right raises it; a drag past either end +/// clamps at that control's range (the zones' own specs, ±100 for the four, ±5 EV for Exposure). +#[test] +fn a_drag_lowers_and_clamps_at_the_controls_range() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + drag(&mut h, 0.5, -w * 0.2, false); + assert!((value(&h, "light.exposure") + 2.0).abs() <= 0.02, "{}", value(&h, "light.exposure")); + // the Blacks zone, dragged right across the whole plot and beyond: clamped at +100 + drag(&mut h, 0.08, w * 0.9, false); + assert_eq!(value(&h, "light.blacks"), 100.0); + drag(&mut h, 0.92, -w * 2.0, false); + assert_eq!(value(&h, "light.whites"), -100.0); +} + +/// Shift is a tenth of the travel, as on the sliders. +#[test] +fn shift_drags_a_tenth_as_far() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + drag(&mut h, 0.5, w * 0.1, false); + let plain = value(&h, "light.exposure"); + assert!((plain - 1.0).abs() <= 0.02, "{plain}"); + set(&mut h, "light.exposure", 0.0); + drag(&mut h, 0.5, w * 0.1, true); + let fine = value(&h, "light.exposure"); + assert!((fine - 0.1).abs() <= 0.02, "a tenth of {plain}: {fine}"); +} + +/// A double-click in a zone puts that zone's control back to its default in one undo step, leaving +/// the other zones' controls alone. +#[test] +fn double_clicking_a_zone_resets_that_control_in_one_undo_step() { + let mut h = edit_panel(); + set(&mut h, "light.exposure", 1.25); + set(&mut h, "light.shadows", 40.0); + let undo = h.app.session.undo.len(); + double_click(&mut h, 0.5); + assert_eq!(value(&h, "light.exposure"), 0.0, "the Exposure zone is reset"); + assert_eq!(value(&h, "light.shadows"), 40.0, "only the zone that was clicked"); + assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step"); + // the Shadows zone (0.15–0.35) resets its own control, once the double-click window has passed + after(&mut h, 0.7); + double_click(&mut h, 0.25); + assert_eq!(value(&h, "light.shadows"), 0.0); + // a click on the histogram toggles the clipping warnings (as before this feature): the two + // clicks of a double-click toggle them twice, so the state is where it started + assert!(!h.app.ui.show_clipping); +} + +/// The strip under the plot — the readout and the EXIF line — is not a zone: a drag there leaves +/// every control alone. +#[test] +fn a_drag_below_the_plot_leaves_the_tones_alone() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + let undo = h.app.session.undo.len(); + let p = json!({"id": "histogram", "fx": 0.5, "fy": 0.97, "dx": w * 0.2, "steps": 6}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); + assert_eq!(values(&h), [0.0; 5], "no zone was dragged"); + assert_eq!(h.app.session.undo.len(), undo, "and nothing was recorded"); + assert!(h.app.session.interaction.is_none(), "no interaction was opened"); +} + +/// A drag that starts on a clipping chevron (shadows or highlights) changes no develop tone: the +/// chevrons are clipping controls, not tonal zones, even though they sit inside the plot. +#[test] +fn a_drag_starting_on_a_clipping_chevron_changes_no_develop_value() { + let mut h = edit_panel(); + let undo = h.app.session.undo.len(); + // Drag starting on the shadow clipping indicator (which sits over the Blacks zone) + let p = json!({"id": "histogram:shadows", "dx": 40.0, "steps": 8}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); + assert_eq!(values(&h), [0.0; 5], "dragging from the shadow chevron moves no tone"); + assert_eq!(h.app.session.undo.len(), undo, "and leaves undo alone"); + assert!(h.app.session.interaction.is_none(), "no interaction was opened"); + + // Drag starting on the highlight clipping indicator (which sits over the Whites zone) + let p = json!({"id": "histogram:highlights", "dx": -40.0, "steps": 8}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); + assert_eq!(values(&h), [0.0; 5], "dragging from the highlight chevron moves no tone"); + assert_eq!(h.app.session.undo.len(), undo, "undo remains untouched"); + assert!(h.app.session.interaction.is_none(), "no interaction was opened"); +} + +/// Hovering a zone highlights it (the band covers that zone's share of the plot) and names it with +/// its value in the readout; off the histogram both are gone. +#[test] +fn hovering_a_zone_highlights_it_and_shows_its_name_and_value() { + let mut h = edit_panel(); + let hist = rect(&h, "histogram"); + set(&mut h, "light.shadows", 25.0); + hover(&mut h, 0.25); + let band = rect(&h, "histogram:zone"); + let share = |x: f32| hist.left() + hist.width() * x; + assert!(band.left() >= share(0.14) && band.right() <= share(0.36), "the Shadows band: {band:?} in {hist:?}"); + assert!(h.painted_text().iter().any(|t| t == "Shadows: +25"), "{:?}", h.painted_text()); + // the Exposure zone, with its EV unit + hover(&mut h, 0.5); + let band = rect(&h, "histogram:zone"); + assert!(band.left() >= share(0.34) && band.right() <= share(0.66), "the Exposure band: {band:?}"); + assert!(h.painted_text().iter().any(|t| t == "Exposure: 0.00 EV"), "{:?}", h.painted_text()); + // the pointer off the histogram: no band, no zone readout (the EXIF line is back) + let r = h.request("ui.move", json!({"x": 600.0, "y": 500.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(widget(&h, "histogram:zone").is_none(), "the highlight is gone"); + assert!(!h.painted_text().iter().any(|t| t.starts_with("Shadows: ")), "{:?}", h.painted_text()); +} + +/// The pointer over the histogram says the zones can be dragged sideways; elsewhere the canvas' +/// own cursor is back. +#[test] +fn the_cursor_over_the_histogram_offers_a_sideways_drag() { + let mut h = edit_panel(); + hover(&mut h, 0.5); + assert_eq!(h.last_cursor, egui::CursorIcon::ResizeHorizontal); + let r = h.request("ui.move", json!({"x": 600.0, "y": 500.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_ne!(h.last_cursor, egui::CursorIcon::ResizeHorizontal); +} + +/// A drag dispatches as it goes, with the interaction held open, so the draft render follows it +/// live; nothing is committed until the release, which lands the whole drag as one undo step. +#[test] +fn a_drag_dispatches_live_and_commits_on_the_release() { + let mut h = edit_panel(); + let before = h.app.session.undo.len(); + let hist = rect(&h, "histogram"); + let from = egui::pos2(hist.left() + hist.width() * 0.5, hist.top() + hist.height() * 0.5); + let to = from + egui::vec2(hist.width() * 0.1, 0.0); + // a press and its moves with no release: the drag is still going when the frames below run + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + for i in 1..=6 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + } + for _ in 0..12 { + h.step(); + } + let exposure = value(&h, "light.exposure"); + assert!(exposure > 0.4, "the exposure followed the drag while it was still going: {exposure}"); + assert!(h.app.session.interaction.is_some(), "the drag holds the interaction open"); + assert_eq!(h.app.ui.dragging_control.as_deref(), Some("light.exposure"), "the drag names the control it moves, as the sliders do"); + assert_eq!(h.app.session.undo.len(), before, "nothing is committed mid-drag"); + // the release: one undo step for the whole drag, and the interaction is closed + h.app.synthetic.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.settle(SETTLE); + assert!(h.app.session.interaction.is_none(), "the release commits the interaction"); + assert!(h.app.ui.dragging_control.is_none(), "and no control is left dragged"); + assert_eq!(h.app.session.undo.len() - before, 1, "the whole drag is one undo step"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9790523e1..98b28e34f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | -| Lightroom Classic extras | 40 | 25 | 21 | 8 | — | 24/26 (92%) | -| **Total** | 421 | 64 | 57 | 35 | 196/203 (97%) | 147/161 (91%) | +| Lightroom Classic extras | 41 | 25 | 21 | 8 | — | 24/26 (92%) | +| **Total** | 422 | 64 | 57 | 35 | 196/203 (97%) | 147/161 (91%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.6%** of 542 in-scope rows — P0 98.3% of 203 · P1 94.7% of 161 · P2 56.5% of 177. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.6%** of 543 in-scope rows — P0 98.3% of 203 · P1 94.7% of 161 · P2 56.7% of 178. ## Top gaps and shortcut notes @@ -139,7 +139,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | -| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; the plot's five tonal zones also drag to adjust their Basic control (LRC-DEV-HISTOGRAM-DRAG); it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | | LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | |, `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet; | ## D. Search & filter (FILT) @@ -733,6 +733,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-VIEWOPTIONS | Develop view options | P2 | 🟡 | `cmd:view.viewOptions`, `crates/ui-egui/src/panels/settings.rs` (`view_options`) | View ▸ View Options… (⌘J), the Loupe View part: Show Info Overlay with Info 1 / Info 2, and the three lines of each (none, file name, capture date, dimensions, exposure, camera, lens, camera + lens) with Use Defaults; applied as they change, saved with the app settings; `view.viewOptions {info1, info2}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), "show briefly when the photo changes", the loading-message and video-frame options | | LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | +| LRC-DEV-HISTOGRAM-DRAG | Drag on the histogram to adjust tone regions | P2 | ✅ | `crates/ui-egui/src/panels/histogram.rs`, `crates/ui-egui/src/panels/edit.rs`, `crates/develop/src/controls.rs`, `crates/ui-egui/src/tests_histogram_drag.rs`, `cmd:develop.set`, `cmd:develop.beginInteraction`, `cmd:develop.endInteraction`, `ctl:light.blacks`, `ctl:light.shadows`, `ctl:light.exposure`, `ctl:light.highlights`, `ctl:light.whites` | five zones across the plot's width, each moving the Basic control it names: Blacks 0.00–0.15, Shadows 0.15–0.35, Exposure 0.35–0.65, Highlights 0.65–0.85, Whites 0.85–1.00; every step is a `develop.set` between `develop.beginInteraction` and `develop.endInteraction` (the sliders' own path), so the draft render follows the drag live and the whole drag lands as one undo step; the zone the press landed in stays locked for the drag, so dragging across the others changes nothing; sideways travel of the plot's width sweeps that control's whole range (Shift a tenth, as on the sliders), clamped to its spec; hovering a zone tints it and shows its value in the strip under the plot ("Exposure: +0.45 EV"), with the sideways-resize cursor; a double-click resets that zone's control to its default in one undo step; the strip under the plot (readout / EXIF) and the clipping buttons in the plot's top corners are not zones | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | | | LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | | | LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index c09dddc09..df94211c0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -150,6 +150,12 @@ ## 2026-10-16 +### The histogram's tonal zones adjust the Basic tones +- The histogram is a control surface again, as in Lightroom Classic: hovering one of its five zones (Blacks, Shadows, + Exposure, Highlights, Whites) tints it and shows its name and value under the plot ("Exposure: +0.45 EV"). Dragging one + sideways moves that control — the zone you pressed stays locked while the pointer crosses the others, Shift drags a + tenth as far, and the whole drag is one undo step. Double-clicking a zone puts its control back to its default. + ### Synchronize Folder shows that it is listing the folder - A folder with tens of thousands of photos, above all on a network share, can take minutes to list before Synchronize Folder checks a single photo. Its row in the activity stack used to sit at "0 of 64085" with an empty