diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index be5866fdc..1e84313ae 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -285,7 +285,7 @@ "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Kliknutím na fotografii vyberete barvu; ⇧-kliknutím přidáte další (nejvýše 5)", "Click to clear these filters": "Kliknutím zrušíte tyto filtry", "Click to restore · double-click to rename": "Kliknutím obnovíte · dvojklikem přejmenujete", - "Click to type a value": "Kliknutím můžete zadat hodnotu", + "Click to type a value, drag to change it": "Kliknutím můžete zadat hodnotu, tažením ji změníte", "Clone": "Klonovat", "Close": "Zavřít", "Close Panel": "Zavřít panel", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index e1168f126..5a8ba595a 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1919,7 +1919,7 @@ "commercial use subject to GPL conditions": "kommerzielle Nutzung unter GPL-Bedingungen", "Decrease Rating": "Bewertung verringern", "Increase Rating": "Bewertung erhöhen", - "Click to type a value": "Klicken, um einen Wert einzugeben", + "Click to type a value, drag to change it": "Klicken, um einen Wert einzugeben, ziehen, um ihn zu ändern", "Angle": "Winkel", "Video": "Video", "Copyrighted": "Urheberrechtlich geschützt", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 77a175721..516b90da5 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1943,7 +1943,7 @@ "Increase Rating": "Aumentar valoración", "Index Faces": "Indexar caras", "Index Faces in the Background": "Indexar caras en segundo plano", - "Click to type a value": "Hacer clic para escribir un valor", + "Click to type a value, drag to change it": "Hacer clic para escribir un valor, arrastrar para cambiarlo", "Angle": "Ángulo", "Video": "Vídeo", "Copyrighted": "Con copyright", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index d70878dfd..81bf761f3 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1753,7 +1753,7 @@ "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Appliquer le mappage local des tons enregistré dans le fichier raw (Apple ProRAW), comme le rend l’appareil", "Decrease Rating": "Diminuer la note", "Increase Rating": "Augmenter la note", - "Click to type a value": "Cliquez pour saisir une valeur", + "Click to type a value, drag to change it": "Cliquez pour saisir une valeur, faites glisser pour la modifier", "Angle": "Angle", "Video": "Vidéo", "Copyrighted": "Protégé par copyright", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index db459dd70..3b6342e0e 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1763,7 +1763,7 @@ "commercial use subject to GPL conditions": "商用利用は GPL の条件に従う", "Decrease Rating": "評価を下げる", "Increase Rating": "評価を上げる", - "Click to type a value": "クリックして値を入力", + "Click to type a value, drag to change it": "クリックして値を入力、ドラッグして変更", "Angle": "角度", "Video": "動画", "Copyrighted": "著作権あり", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index b03c2fcd3..7416ce241 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -1745,7 +1745,7 @@ "commercial use subject to GPL conditions": "użytek komercyjny na warunkach licencji GPL", "Decrease Rating": "Zmniejsz ocenę", "Increase Rating": "Zwiększ ocenę", - "Click to type a value": "Kliknij, aby wpisać wartość", + "Click to type a value, drag to change it": "Kliknij, aby wpisać wartość, przeciągnij, aby ją zmienić", "Angle": "Kąt", "Video": "Wideo", "Copyrighted": "Chronione prawem autorskim", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 14c30f937..db52efc1f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1763,7 +1763,7 @@ "commercial use subject to GPL conditions": "uso comercial sujeito às condições da GPL", "Decrease Rating": "Diminuir avaliação", "Increase Rating": "Aumentar avaliação", - "Click to type a value": "Clique para digitar um valor", + "Click to type a value, drag to change it": "Clique para digitar um valor, arraste para alterá-lo", "Angle": "Ângulo", "Video": "Vídeo", "Copyrighted": "Protegido por direitos autorais", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 29c723d03..fc65d64d3 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1436,7 +1436,7 @@ "commercial use subject to GPL conditions": "коммерческое использование на условиях GPL", "Decrease Rating": "Уменьшить оценку", "Increase Rating": "Повысить оценку", - "Click to type a value": "Щёлкните, чтобы ввести значение", + "Click to type a value, drag to change it": "Щёлкните, чтобы ввести значение, или перетащите, чтобы изменить", "Angle": "Угол", "Raw": "RAW", "Video": "Видео", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 8bb244ac2..f7ee40a08 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1947,7 +1947,7 @@ "Recognition models": "Моделі розпізнавання", "Decrease Rating": "Зменшити оцінку", "Increase Rating": "Збільшити оцінку", - "Click to type a value": "Клацніть, щоб ввести значення", + "Click to type a value, drag to change it": "Клацніть, щоб ввести значення, або перетягніть, щоб змінити", "Angle": "Кут", "Video": "Відео", "Copyrighted": "Захищено авторським правом", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index d60edc75b..e2d3a5bfa 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1763,7 +1763,7 @@ "commercial use subject to GPL conditions": "商用须遵循 GPL 条款", "Decrease Rating": "降低星级", "Increase Rating": "提高星级", - "Click to type a value": "单击以输入数值", + "Click to type a value, drag to change it": "单击以输入数值,拖动以更改", "Angle": "角度", "Video": "视频", "Copyrighted": "受版权保护", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ce3b17f32..542b9a6ca 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1763,7 +1763,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "Decrease Rating": "降低評分", "Increase Rating": "提高評分", - "Click to type a value": "點擊以輸入數值", + "Click to type a value, drag to change it": "點擊以輸入數值,拖曳以變更", "Angle": "角度", "Video": "影片", "Copyrighted": "受著作權保護", diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index 8e27411eb..f19171ffc 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -121,7 +121,7 @@ fn the_straighten_value_says_it_can_be_typed() { // tooltips wait for the pointer to rest let _ = h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t.contains("type"))); assert!( - h.painted_text().iter().any(|t| t == "Click to type a value"), + h.painted_text().iter().any(|t| t == "Click to type a value, drag to change it"), "{:?}", h.painted_text().iter().filter(|t| t.contains("lick")).collect::>() ); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 67fa4d394..c85508c4e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -218,10 +218,14 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { if shown == "+0" || shown == "-0" { "0".to_string() } else { shown } } +/// Pointer travel (points, sideways from the press) that turns a press on a slider's value into a +/// scrub rather than a click that types a value. +const SCRUB_START: f32 = 3.0; + /// A Lightroom slider row (label + value above a track with a hollow ring thumb). /// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Scroll sideways over /// it to nudge it. Click the value to type one: Return or clicking away applies it, Esc keeps the -/// old one. +/// old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth as far). pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -260,16 +264,40 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); crate::widgets::announce(&label_resp, crate::i18n::tr_format!("Reset {name}", name = label_text.clone())); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); - // the value: click it to type an exact one (over the label, so it takes the click) + // the value: click it to type an exact one, drag it sideways to scrub (over the label, so it + // takes the click) let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); - let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() }); - crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value"))); + let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); + crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it"))); register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect); // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) - let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp }; + let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp }; let typing_id = id.with("typing"); // Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit. let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id)); + // A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead + // of rounding back to the same step every frame. + let scrub_id = id.with("scrub-pos"); + let mut scrub: Option = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None }; + let mut scrub_started = false; + if enabled + && scrub.is_none() + && value_resp.dragged() + && let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) + && (p.x - origin.x).abs() >= SCRUB_START + { + // a press on the value that moves sideways scrubs: the edit the press opened is dropped + typing = None; + ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); + ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); + // the travel so far counts, so the value doesn't lag the pointer by the start distance + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0)); + scrub_started = true; + } + if scrub.is_none() { + ui.data_mut(|m| m.remove::(scrub_id)); + } let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); // Focus on the owned primary press before fast typing; retain the readout's click activation. if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { @@ -287,7 +315,28 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width(); let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width())); let mut v = value; - if let Some((mut text, frames, pressed)) = typing.take() { + if let Some(mut pos) = scrub { + // scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel + // over the track's width (a tenth with Shift), on the control's steps + if !scrub_started && value_resp.dragged() { + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + let dx = ui.input(|i| i.pointer.delta().x); + pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0); + } + let step = spec.step.max(1e-9); + let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max); + if (nv - value).abs() > 1e-12 { + out.value = Some(nv); + v = nv; + } + out.drag_started = scrub_started; + if value_resp.drag_stopped() { + out.drag_stopped = true; + ui.data_mut(|m| m.remove::(scrub_id)); + } else { + ui.data_mut(|m| m.insert_temp(scrub_id, pos)); + } + } else if let Some((mut text, frames, pressed)) = typing.take() { // Keep the owned opening release focused without restarting its draft or selection. // egui gives that release's click to the readout, so the field under it gives up the focus // whatever frame it lands on (issue #1362): the field takes it back after drawing too. @@ -454,8 +503,9 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ p.text(label_rect.left_center(), Align2::LEFT_CENTER, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c); if typing.is_none() { p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c); - if enabled && value_resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + // the scrub pointer, as over Lightroom's values: a drag here changes the value + if enabled && (value_resp.hovered() || scrub.is_some()) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } } let ring = 7.0; diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs index 5e54ff064..278ff3ba8 100644 --- a/crates/ui-egui/src/widgets/tests_numeric_entry.rs +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -11,6 +11,7 @@ struct Harness { enabled: bool, time: f64, rect: Rect, + track: Rect, } impl Harness { fn new() -> Self { @@ -21,6 +22,7 @@ impl Harness { enabled: true, time: 0.0, rect: Rect::NOTHING, + track: Rect::NOTHING, }; for _ in 0..3 { h.frame(vec![]); @@ -33,7 +35,10 @@ impl Harness { self.view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, self.time, events), |ui| { out = slider(ui, &self.spec, self.value, self.enabled, None); }); - self.rect = take_registry(&self.view.ctx).into_iter().find(|(id, _)| id == &format!("sliderValue:{}", self.spec.id)).unwrap().1; + let registry = take_registry(&self.view.ctx); + let find = |name: String| registry.iter().find(|(id, _)| id == &name).map(|(_, r)| *r); + self.rect = find(format!("sliderValue:{}", self.spec.id)).unwrap(); + self.track = find(format!("slider:{}", self.spec.id)).unwrap(); if let Some(v) = out.value { self.value = v; } @@ -64,9 +69,10 @@ fn opening_release_honors_return_and_escape_immediately() { #[test] fn dragging_away_from_readout_does_not_capture_keyboard() { + // straight down: no sideways travel, so the press neither types nor scrubs let mut h = Harness::new(); let inside = h.rect.center(); - let outside = egui::pos2(5.0, 80.0); + let outside = egui::pos2(inside.x, 80.0); let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; for events in [vec![Event::PointerMoved(inside)], vec![button(inside, true)], vec![Event::PointerMoved(outside)], vec![button(outside, false)]] { let out = h.frame(events); @@ -94,3 +100,79 @@ fn typing_after_a_released_click_still_edits() { h.frame(vec![Event::Text("2".into())]); assert_eq!(h.key(Key::Enter).value, Some(2.0)); } + +impl Harness { + /// The track's width: a scrub moves the value as far as dragging the thumb would. + fn track_width(&self) -> f32 { + self.track.width() + } + /// Press on the value, move sideways by `dx` in `steps` frames (Shift held when `shift`) and release; + /// the outputs of every frame. + fn scrub(&mut self, dx: f32, steps: usize, shift: bool) -> Vec { + let modifiers = if shift { Modifiers::SHIFT } else { Modifiers::NONE }; + let start = self.rect.center(); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + let mut outs = vec![self.frame(vec![Event::ModifiersChanged(modifiers), Event::PointerMoved(start)]), self.frame(vec![button(start, true)])]; + for i in 1..=steps { + let pos = start + vec2(dx * i as f32 / steps as f32, 0.0); + outs.push(self.frame(vec![Event::PointerMoved(pos)])); + } + outs.push(self.frame(vec![button(start + vec2(dx, 0.0), false)])); + outs.push(self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)])); + outs + } +} + +#[test] +fn dragging_the_value_sideways_scrubs_it_like_the_thumb() { + let mut h = Harness::new(); + let width = f64::from(h.track_width()); + assert!(width > 100.0, "the slider has a track ({width})"); + // a fifth of the track: a fifth of the range (Exposure −5..+5 → +2 EV) + let outs = h.scrub((width / 5.0) as f32, 10, false); + assert!((h.value - 2.71).abs() < 0.015, "scrubbed to {}", h.value); + assert_eq!(outs.iter().filter(|o| o.drag_started).count(), 1, "one interaction begins"); + assert_eq!(outs.iter().filter(|o| o.drag_stopped).count(), 1, "and ends, so the scrub is one undo step"); + assert!(outs.iter().filter_map(|o| o.value).count() > 1, "the value follows the pointer while it moves"); + assert!(!h.view.ctx.egui_wants_keyboard_input(), "a scrub opens no value field"); + assert!(h.key(Key::Enter).value.is_none(), "and leaves no draft to commit"); +} + +#[test] +fn scrubbing_back_and_with_shift() { + let mut h = Harness::new(); + let width = h.track_width(); + // left lowers the value; on the control's steps and inside its range + h.scrub(-width, 8, false); + assert_eq!(h.value, -5.0, "clamped at the minimum"); + // Shift moves a tenth as far + let mut h = Harness::new(); + h.scrub(width / 5.0, 10, true); + assert!((h.value - 0.91).abs() < 0.015, "fine scrub to {}", h.value); + assert!(((h.value / 0.01).round() * 0.01 - h.value).abs() < 1e-9, "on the 0.01 steps"); +} + +#[test] +fn a_press_that_barely_moves_still_types() { + // a hand's jitter under the scrub distance is a click: the value can be typed + let mut h = Harness::new(); + let pos = h.rect.center(); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.frame(vec![Event::PointerMoved(pos)]); + h.frame(vec![button(pos, true)]); + h.frame(vec![Event::PointerMoved(pos + vec2(1.5, 0.5))]); + assert_eq!(h.frame(vec![button(pos + vec2(1.5, 0.5), false)]).value, None); + h.frame(vec![]); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the field keeps the keyboard after the click"); + h.frame(vec![Event::Text("2".into())]); + assert_eq!(h.key(Key::Enter).value, Some(2.0)); +} + +#[test] +fn a_disabled_value_does_not_scrub() { + let mut h = Harness::new(); + h.enabled = false; + let width = h.track_width(); + assert!(h.scrub(width / 5.0, 10, false).iter().all(|o| o.value.is_none() && !o.drag_started)); + assert_eq!(h.value, 0.71); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ee05a4f55..30e78bf6c 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-14 · **Change:** minor (catalog import: persistent result, offline Local promotion and library-switch dismissal; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (KEY-SLIDER-SCRUB: scrub a slider's value by dragging it; catalog import: persistent result, offline Local promotion and library-switch dismissal; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry; LR-IMP-FORMATS: preview-only raws rechecked after an update, #662) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-VIEW-ZOOM: full-size slider-drag drafts on the GPU, timed with their frame, issue #330; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 @@ -38,7 +38,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | +| K. Masking (MASK) | 15 | 5 | 5 | 0 | 7/8 (88%) | 7/7 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -51,13 +51,13 @@ cited here still exists. Formerly docs/parity.md. | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 23 | 7 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | +| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | -| Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) | -| Lightroom Classic extras | 36 | 22 | 27 | 8 | — | 24/26 (92%) | -| **Total** | 411 | 59 | 64 | 35 | 193/201 (96%) | 146/159 (92%) | +| Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | +| Lightroom Classic extras | 36 | 23 | 27 | 8 | — | 24/26 (92%) | +| **Total** | 414 | 61 | 63 | 35 | 193/201 (96%) | 148/161 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.5%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 52.9% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.6%** of 538 in-scope rows — P0 98.0% of 201 · P1 95.0% of 161 · P2 53.4% of 175. ## Top gaps and shortcut notes @@ -676,6 +676,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | | KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each. Scrolling sideways over any slider (trackpad swipe, tilting wheel, ⇧+wheel) nudges it the same way, one wheel notch a step, without selecting it first; only a mostly sideways movement counts, so scrolling the panel leaves sliders alone (`crates/ui-egui/src/tests_masking.rs`, `sideways_scrolling_adjusts_the_slider_under_the_pointer`) | | KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts | +| KEY-SLIDER-SCRUB | Scrub a slider value — drag the number | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`slider`, `SCRUB_START`), `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`dragging_the_value_sideways_scrubs_it_like_the_thumb`, `scrubbing_back_and_with_shift`, `a_press_that_barely_moves_still_types`) | drag the value next to any slider's name sideways: it moves as dragging the thumb would (the pointer's travel over the track's width, ⇧ a tenth as far), on the control's steps and range, as one undo step; a press that moves less than 3 points sideways stays a click that types a value (KEY-SLIDER-TYPE); the pointer over the value is the horizontal-resize cursor, as Lightroom's scrubby value | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | | KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | | | KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | @@ -817,6 +818,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | KEYC-RATING: backtick cycles the flag (`photo.cycleFlag`), ⌘↑ / ⌘↓ raise / lower it (`photo.increaseFlag`, `photo.decreaseFlag`); status unchanged (🟡) | | 2026-10-13 | patch | LR-MASK-LUMRANGE / LR-MASK-COLORRANGE: range drags keep the grabbed endpoint; colour samples use the render's enabled sections and HDR/SDR settings; regression coverage, status unchanged | | 2026-10-13 | minor | LR-IMP-DEVICE: Settings → Import → Cameras and Cards can open the import review when a camera or card is connected; status unchanged (✅) | +| 2026-10-13 | minor | KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it, as in Lightroom (new row) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |