Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-15 02:28:24 -04:00
co-authored by Claude Opus 5.5
15 changed files with 163 additions and 29 deletions
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "著作権あり",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "Видео",
+1 -1
View File
@@ -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": "Захищено авторським правом",
+1 -1
View File
@@ -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": "受版权保护",
+1 -1
View File
@@ -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": "受著作權保護",
+1 -1
View File
@@ -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::<Vec<_>>()
);
+58 -8
View File
@@ -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<f64> = 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::<f64>(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::<f64>(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;
@@ -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<SliderOut> {
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);
}
+9 -7
View File
@@ -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.
<!-- /parity:summary -->
## 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 (🟡) |