Histogram: drag its tonal zones to adjust the Basic tones

Lightroom Classic's histogram is a control surface: its plot is split into five
tonal zones and each one moves the Basic control it names. The Edit panel's
histogram now does the same, wired through the engine like every slider.

- Zones: Blacks 0.00-0.15, Shadows 0.15-0.35, Exposure 0.35-0.65, Highlights
  0.65-0.85 and Whites 0.85-1.00 of the plot's width, each naming the develop
  control it adjusts (light.blacks ... light.whites), so its range, default and
  steps are the panel's own control specs -- no copy of them.
- The zone the press landed in stays locked for the whole drag (the new
  Gesture::HistogramDrag), so the pointer may cross the others without switching
  control. The strip under the plot (the readout and the EXIF line) and the
  clipping buttons in the plot's top corners are not zones.
- A sideways drag dispatches develop.set between develop.beginInteraction and
  develop.endInteraction -- the sliders' own path -- so the draft render follows
  the drag live, nothing is committed while it goes on, and the release lands the
  whole drag as one undo step. Travel of the plot's width sweeps the control's
  whole range (Shift a tenth, as the sliders' fine drag) and clamps to its spec;
  the unrounded value is carried from frame to frame so a slow drag adds up
  rather than rounding back to the step it already has, and the first reported
  frame takes the whole travel from the press, since egui withholds a drag until
  the pointer has left its click threshold.
- Hovering a zone tints it and names it with its value in the strip under the
  plot ("Exposure: +0.45 EV"), replacing the EXIF line, and the pointer shows the
  sideways-resize cursor. A double-click in a zone puts that control back to its
  default in one undo step.

Tests: the zone mapping at every boundary, the tiling and the controls' specs,
the drag/rounding/clamping arithmetic (hostile input included) and the readout
wording; end to end through the control channel: a drag per zone, the zone lock
across the plot, the Shift ratio, clamping at both ends, live dispatch with one
undo step on the release, the double-click reset, the hover highlight, readout
and cursor, and a drag on the readout strip or clipping chevrons changing nothing.

Docs: the tracker's LRC-DEV-HISTOGRAM-DRAG row added and LR-VIEW-HISTOGRAM's
"no drag-to-adjust" note replaced, with the summary regenerated by
`cargo xtask parity --write`; a dated entry in docs/whats-new.md.

Sources: own design; the zone layout and Lightroom Classic's histogram-region
behaviour as the reference (observed read-only).

Co-Authored-By: deepseek-flash <noreply@deepseek.com>
This commit is contained in:
deunderscorepaul
2026-10-24 00:17:07 +02:00
committed by Paul
co-authored by deepseek-flash
parent 98702b5c44
commit 79e1dea851
8 changed files with 707 additions and 14 deletions
+3
View File
@@ -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;
+7
View File
@@ -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.
+17 -9
View File
@@ -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<String> = [
m.iso.map(|i| format!("ISO {i}")),
+372
View File
@@ -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<Zone> {
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<String> {
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<Active> {
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<Rect> = 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);
}
}
+1
View File
@@ -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;
+295
View File
@@ -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<egui::Rect> {
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<f64> {
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");
}
+6 -5
View File
@@ -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.
<!-- /parity:summary -->
## 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 | ⬜ | | |
+6
View File
@@ -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