From ab64e9a563bcfbe1ca200f17f466ad20d6bdb998 Mon Sep 17 00:00:00 2001 From: good-news-everyone Date: Tue, 13 Oct 2026 19:55:17 +0300 Subject: [PATCH] KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it As over Lightroom's values, a press on the number next to a slider's name that moves sideways now changes the value as dragging the thumb would: the pointer's travel over the track's width, a tenth as far with Shift, on the control's steps and range, as one undo step. A press that moves less than 3 points sideways is still a click that opens the value for typing (KEY-SLIDER-TYPE), and the pointer over the value is the horizontal-resize cursor. The tooltip says both ("Click to type a value, drag to change it"), translated in every catalog. Tests: scrub distance and direction, Shift, clamping, steps, one interaction per scrub, no field or draft left behind, a jittery click still types, a disabled slider doesn't scrub. The readout test that drags away from the value now drags straight down, so it still checks that a cancelled press keeps neither a value nor the keyboard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/es.json | 2 +- crates/ui-egui/locales/fr.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/pt-br.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 2 +- crates/ui-egui/src/tests_crop_rotate.rs | 2 +- crates/ui-egui/src/widgets.rs | 65 ++++++++++++-- .../src/widgets/tests_numeric_entry.rs | 86 ++++++++++++++++++- docs/parity-checklist.md | 12 +-- 13 files changed, 159 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..8d2361433 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1911,7 +1911,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "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 ba3e1e331..e7f30f677 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1791,7 +1791,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 b0becb154..a0acb8941 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1745,7 +1745,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 7a282e676..0aae78d39 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1755,7 +1755,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/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..b29b0dba5 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1755,7 +1755,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "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 c840218a0..4903f1620 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1428,7 +1428,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 85a838098..2c162ce96 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1939,7 +1939,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 ad379f1ef..84d36bdbb 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1755,7 +1755,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 647d153d0..8ddd637a5 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1755,7 +1755,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 a6af3dc7a..ffcaab5c5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -176,9 +176,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. Click the value to -/// type one: Return or clicking away applies it, Esc keeps the old one. +/// type one: Return or clicking away applies it, Esc keeps the 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(); @@ -216,15 +221,39 @@ 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()); 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() }); + let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); 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) { @@ -242,7 +271,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. @@ -387,8 +437,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 76d91ba3e..e18837b24 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-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEY-SLIDER-SCRUB: scrub a slider's value by dragging it; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); 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 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 @@ -36,7 +36,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) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (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,11 +51,11 @@ cited here still exists. Formerly docs/parity.md. | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | 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 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | +| Z. Keyboard shortcuts (desktop) | 77 | 3 | 3 | 1 | 49/52 (94%) | 24/25 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 410 | 52 | 72 | 35 | 193/201 (96%) | 147/159 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.3% of 159 · P2 50.0% of 173. ## Top gaps and shortcut notes @@ -669,6 +669,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 | | 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 | ⬜ | | | @@ -789,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 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 (🟡) |